ios_app_v2/yobble/Views/Tab/ChatsTab.swift
2025-10-08 07:35:22 +03:00

1138 lines
41 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// ChatsTab.swift
// VolnahubApp
//
// Created by cheykrym on 09/06/2025.
//
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
struct ChatsTab: View {
var currentUserId: String?
@Binding var searchRevealProgress: CGFloat
@Binding var searchText: String
private let searchService = SearchService()
private let chatService = ChatService()
@AppStorage("chatRowMessageLineLimit") private var messageLineLimitSetting: Int = 2
@StateObject private var viewModel = PrivateChatsViewModel()
@State private var selectedChatId: String?
@State private var searchDragStartProgress: CGFloat = 0
@State private var isSearchGestureActive: Bool = false
@State private var globalSearchResults: [UserSearchResult] = []
@State private var isGlobalSearchLoading: Bool = false
@State private var globalSearchError: String?
@State private var globalSearchTask: Task<Void, Never>? = nil
@State private var creatingChatForUserId: UUID?
@State private var chatCreationError: String?
@State private var isChatCreationAlertPresented: Bool = false
@State private var pendingChatItem: PrivateChatListItem?
@State private var isPendingChatActive: Bool = false
private let searchRevealDistance: CGFloat = 90
init(currentUserId: String? = nil, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
self.currentUserId = currentUserId
self._searchRevealProgress = searchRevealProgress
self._searchText = searchText
}
var body: some View {
content
.background(Color(UIColor.systemBackground))
.onAppear {
viewModel.loadInitialChats()
handleSearchQueryChange(searchText)
}
.onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
viewModel.refresh()
}
.onChange(of: searchText) { newValue in
handleSearchQueryChange(newValue)
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && searchRevealProgress < 1 {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
searchRevealProgress = 1
}
}
}
.alert(
NSLocalizedString("Не удалось открыть чат", comment: "Chat creation error title"),
isPresented: Binding(
get: { isChatCreationAlertPresented && chatCreationError != nil },
set: { newValue in
isChatCreationAlertPresented = newValue
if !newValue {
chatCreationError = nil
}
}
),
presenting: chatCreationError
) { _ in
Button(NSLocalizedString("Понятно", comment: "Chat creation error acknowledgment"), role: .cancel) { }
} message: { error in
Text(error)
}
.onDisappear {
globalSearchTask?.cancel()
globalSearchTask = nil
}
}
@ViewBuilder
private var content: some View {
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
loadingState
} else {
chatList
}
}
private var chatList: some View {
ZStack {
List {
// VStack(spacing: 0) {
// searchBar
// .padding(.horizontal, 16)
// .padding(.vertical, 8)
// }
// .background(Color(UIColor.systemBackground))
if let message = viewModel.errorMessage {
Section {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text(message)
.font(.subheadline)
.foregroundColor(.orange)
Spacer(minLength: 0)
Button(action: { viewModel.refresh() }) {
Text(NSLocalizedString("Обновить", comment: ""))
.font(.subheadline)
}
}
.padding(.vertical, 4)
}
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
if isSearching {
Section(header: localSearchHeader) {
if localSearchResults.isEmpty {
emptySearchResultView
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
.listRowSeparator(.hidden)
} else {
ForEach(localSearchResults) { chat in
chatRowItem(for: chat)
}
}
}
Section(header: globalSearchHeader) {
globalSearchContent
}
} else {
if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
errorState(message: message)
} else if viewModel.chats.isEmpty {
emptyState
} else {
ForEach(viewModel.chats) { chat in
chatRowItem(for: chat)
}
if viewModel.isLoadingMore {
loadingMoreRow
}
}
}
}
.listStyle(.plain)
.modifier(ScrollDismissesKeyboardModifier())
.simultaneousGesture(searchBarGesture)
.simultaneousGesture(tapToDismissKeyboardGesture)
// .safeAreaInset(edge: .top) {
// VStack(spacing: 0) {
// searchBar
// .padding(.horizontal, 16)
// .padding(.top, 8)
// .padding(.bottom, 8)
// Divider()
// }
// .background(Color(UIColor.systemBackground))
// }
pendingChatNavigationLink
}
}
private var pendingChatNavigationLink: some View {
NavigationLink(
destination: pendingChatDestination,
isActive: Binding(
get: { isPendingChatActive && pendingChatItem != nil },
set: { newValue in
if !newValue {
isPendingChatActive = false
pendingChatItem = nil
}
}
)
) {
EmptyView()
}
.hidden()
}
@ViewBuilder
private var pendingChatDestination: some View {
if let pendingChatItem {
PrivateChatView(chat: pendingChatItem, currentUserId: currentUserId)
} else {
EmptyView()
}
}
private var searchBarGesture: some Gesture {
DragGesture(minimumDistance: 10, coordinateSpace: .local)
.onChanged { value in
let verticalTranslation = value.translation.height
let horizontalTranslation = value.translation.width
if !isSearchGestureActive {
guard abs(verticalTranslation) > abs(horizontalTranslation) else { return }
if searchRevealProgress <= 0.001 && verticalTranslation < 0 { return }
isSearchGestureActive = true
searchDragStartProgress = searchRevealProgress
}
guard isSearchGestureActive else { return }
let delta = verticalTranslation / searchRevealDistance
let newProgress = searchDragStartProgress + delta
if isSearching && newProgress < 1 {
searchRevealProgress = 1
return
}
searchRevealProgress = max(0, min(1, newProgress))
}
.onEnded { _ in
guard isSearchGestureActive else { return }
isSearchGestureActive = false
if isSearching {
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
searchRevealProgress = 1
}
return
}
let target: CGFloat = searchRevealProgress > 0.5 ? 1 : 0
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
searchRevealProgress = target
}
}
}
private var tapToDismissKeyboardGesture: some Gesture {
TapGesture().onEnded {
dismissKeyboard()
}
}
private var isSearching: Bool {
!searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var messagePreviewLineLimit: Int {
switch messageLineLimitSetting {
case ..<1:
return 1
case 1...2:
return messageLineLimitSetting
default:
return 2
}
}
private var filteredChats: [PrivateChatListItem] {
let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedQuery.isEmpty else { return viewModel.chats }
let lowercasedQuery = trimmedQuery.lowercased()
return viewModel.chats.filter { chat in
let searchableValues = [
chat.chatData?.customName,
chat.chatData?.fullName,
chat.chatData?.login,
chat.lastMessage?.content
]
return searchableValues
.compactMap { $0?.lowercased() }
.contains(where: { $0.contains(lowercasedQuery) })
}
}
private var localSearchResults: [PrivateChatListItem] {
Array(filteredChats.prefix(5))
}
private var localSearchHeader: some View {
Text(NSLocalizedString("Локальные чаты", comment: "Local search section"))
}
private var globalSearchHeader: some View {
Text(NSLocalizedString("Глобальный поиск", comment: "Global search section"))
}
@ViewBuilder
private var globalSearchContent: some View {
if isGlobalSearchLoading {
globalSearchLoadingRow
} else if let error = globalSearchError {
globalSearchErrorRow(message: error)
} else if globalSearchResults.isEmpty {
globalSearchEmptyRow
} else {
ForEach(globalSearchResults) { user in
globalSearchRow(for: user)
}
}
}
private var emptySearchResultView: some View {
VStack(spacing: 8) {
Image(systemName: "text.magnifyingglass")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text(NSLocalizedString("Ничего не найдено", comment: ""))
.font(.headline)
Text(NSLocalizedString("Попробуйте изменить запрос поиска.", comment: ""))
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private var loadingState: some View {
VStack(spacing: 12) {
ProgressView()
Text(NSLocalizedString("Загружаем чаты…", comment: ""))
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func errorState(message: String) -> some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.bubble")
.font(.system(size: 48))
.foregroundColor(.orange)
Text(message)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.primary)
Button(action: { viewModel.loadInitialChats(force: true) }) {
Text(NSLocalizedString("Повторить", comment: ""))
.font(.headline)
}
.buttonStyle(.borderedProminent)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "bubble.left")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
.font(.body)
.foregroundColor(.secondary)
Button(action: { viewModel.refresh() }) {
Text(NSLocalizedString("Обновить", comment: ""))
}
.buttonStyle(.bordered)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.listRowInsets(EdgeInsets(top: 24, leading: 16, bottom: 24, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
private var loadingMoreRow: some View {
HStack {
Spacer()
ProgressView()
.padding(.vertical, 12)
Spacer()
}
.listRowSeparator(.hidden)
}
@ViewBuilder
private func chatRowItem(for chat: PrivateChatListItem) -> some View {
Button {
selectedChatId = chat.chatId
} label: {
ChatRowView(
chat: chat,
currentUserId: currentUserId,
messageLimitLine: messagePreviewLineLimit
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contextMenu {
Button(action: {}) {
Label(NSLocalizedString("Закрепить (скоро)", comment: ""), systemImage: "pin")
}
Button(action: {}) {
Label(NSLocalizedString("Без звука (скоро)", comment: ""), systemImage: "speaker.slash")
}
Button(role: .destructive, action: {}) {
Label(NSLocalizedString("Удалить чат (скоро)", comment: ""), systemImage: "trash")
}
}
.background(
NavigationLink(
destination: PrivateChatView(chat: chat, currentUserId: currentUserId),
tag: chat.chatId,
selection: $selectedChatId
) {
EmptyView()
}
.hidden()
)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
// .listRowSeparator(.hidden)
.onAppear {
guard !isSearching else { return }
viewModel.loadMoreIfNeeded(currentItem: chat)
}
}
private var globalSearchLoadingRow: some View {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Text(NSLocalizedString("Ищем пользователей…", comment: "Global search loading"))
.font(.footnote)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 12)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
}
private func globalSearchErrorRow(message: String) -> some View {
Text(message)
.font(.footnote)
.foregroundColor(.orange)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
}
private var globalSearchEmptyRow: some View {
Text(NSLocalizedString("Ничего не найдено", comment: "Global search empty state"))
.font(.footnote)
.foregroundColor(.secondary)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
}
private func globalSearchRow(for user: UserSearchResult) -> some View {
Button {
openPrivateChat(for: user)
} label: {
HStack(spacing: 12) {
Circle()
.fill(user.isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15))
.frame(width: 44, height: 44)
.overlay(
Text(user.avatarInitial)
.font(.headline)
.foregroundColor(user.isOfficial ? .white : Color.accentColor)
)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text(user.displayName)
.fontWeight(user.isOfficial ? .semibold : .regular)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
if user.isOfficial {
Image(systemName: "checkmark.seal.fill")
.foregroundColor(Color.accentColor)
.font(.caption)
}
}
if let secondary = secondaryLine(for: user) {
Text(secondary)
.font(.footnote)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
if creatingChatForUserId == user.userId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
}
.padding(.vertical, 8)
}
.buttonStyle(.plain)
.disabled(creatingChatForUserId != nil)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
}
private extension ChatsTab {
func dismissKeyboard() {
#if canImport(UIKit)
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
#endif
}
func handleSearchQueryChange(_ query: String) {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
resetGlobalSearch()
return
}
globalSearchTask?.cancel()
globalSearchTask = nil
guard trimmed.count >= 2 else {
globalSearchResults = []
globalSearchError = nil
isGlobalSearchLoading = false
return
}
isGlobalSearchLoading = true
globalSearchError = nil
globalSearchTask = Task {
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
do {
let data = try await searchService.search(query: trimmed)
guard !Task.isCancelled else { return }
await MainActor.run {
globalSearchResults = data.users
isGlobalSearchLoading = false
globalSearchError = nil
globalSearchTask = nil
}
} catch is CancellationError {
// Ignore cancellation
} catch {
guard !Task.isCancelled else { return }
await MainActor.run {
globalSearchResults = []
isGlobalSearchLoading = false
globalSearchError = friendlyErrorMessage(for: error)
globalSearchTask = nil
}
}
}
}
func resetGlobalSearch() {
globalSearchTask?.cancel()
globalSearchTask = nil
globalSearchResults = []
globalSearchError = nil
isGlobalSearchLoading = false
}
func secondaryLine(for user: UserSearchResult) -> String? {
if let official = user.officialFullName {
if let custom = user.preferredCustomName, custom != official {
return "\(user.loginHandle) (\(custom))"
}
// if official != user.loginHandle {
// return user.loginHandle
// }
}
else if let custom = user.preferredCustomName, custom != user.displayName {
return custom
}
if let profileLogin = user.profile?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !profileLogin.isEmpty {
let handle = "@\(profileLogin)"
if handle != user.loginHandle {
return handle
}
}
if user.displayName != user.loginHandle {
return user.loginHandle
}
return nil
}
func openPrivateChat(for user: UserSearchResult) {
guard creatingChatForUserId == nil else { return }
dismissKeyboard()
creatingChatForUserId = user.userId
chatCreationError = nil
chatService.createOrFindPrivateChat(targetUserId: user.userId.uuidString) { result in
creatingChatForUserId = nil
switch result {
case .success(let data):
let chatItem = PrivateChatListItem(
chatId: data.chatId,
chatType: data.chatType,
chatData: chatProfile(from: user),
lastMessage: nil,
createdAt: nil,
unreadCount: 0
)
pendingChatItem = chatItem
isPendingChatActive = true
viewModel.refresh()
case .failure(let error):
chatCreationError = friendlyChatCreationMessage(for: error)
isChatCreationAlertPresented = true
}
}
}
func chatProfile(from user: UserSearchResult) -> ChatProfile {
let profile = user.profile
let login = profile?.login ?? user.login
let fullName = user.officialFullName ?? user.fullName ?? profile?.fullName
let customName = user.preferredCustomName ?? user.customName ?? profile?.customName
return ChatProfile(
userId: user.userId.uuidString,
login: login,
fullName: fullName,
customName: customName,
bio: profile?.bio,
lastSeen: profile?.lastSeen,
createdAt: profile?.createdAt
)
}
func friendlyChatCreationMessage(for error: Error) -> String {
if let chatError = error as? ChatServiceError {
return chatError.errorDescription ?? NSLocalizedString("Не удалось открыть чат.", comment: "Chat creation fallback")
}
if let networkError = error as? NetworkError {
switch networkError {
case .unauthorized:
return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "Chat creation unauthorized")
case .invalidURL, .noResponse:
return NSLocalizedString("Ошибка соединения с сервером.", comment: "Chat creation connection")
case .network(let underlying):
return String(format: NSLocalizedString("Ошибка сети: %@", comment: "Chat creation network error"), underlying.localizedDescription)
case .server(let statusCode, let data):
if let data {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
if let payload = try? decoder.decode(ErrorResponse.self, from: data) {
if let detail = payload.detail?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty {
return detail
}
if let message = payload.data?.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty {
return message
}
}
if let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty {
return raw
}
}
return String(format: NSLocalizedString("Ошибка сервера (%@).", comment: "Chat creation server status"), "\(statusCode)")
}
}
return NSLocalizedString("Произошла неизвестная ошибка. Попробуйте позже.", comment: "Chat creation unknown error")
}
func friendlyErrorMessage(for error: Error) -> String {
if let searchError = error as? SearchServiceError {
return searchError.errorDescription ?? NSLocalizedString("Не удалось выполнить поиск.", comment: "Search error fallback")
}
if let networkError = error as? NetworkError {
switch networkError {
case .unauthorized:
return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "Search error unauthorized")
case .invalidURL, .noResponse:
return NSLocalizedString("Ошибка соединения с сервером.", comment: "Search error connection")
case .network(let underlying):
return underlying.localizedDescription
case .server(let statusCode, _):
return String(format: NSLocalizedString("Сервер вернул ошибку (%d).", comment: "Search error server status"), statusCode)
}
}
if (error as NSError).code == NSURLErrorCancelled { // доп. подстраховка
return NSLocalizedString("Поиск отменён.", comment: "Search cancelled")
}
return NSLocalizedString("Произошла неизвестная ошибка.", comment: "Search unknown error")
}
}
private struct ScrollDismissesKeyboardModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 16.0, *) {
content.scrollDismissesKeyboard(.interactively)
} else {
content
}
}
}
private struct SearchResultPlaceholderView: View {
let userId: UUID
var body: some View {
VStack(spacing: 16) {
Text(NSLocalizedString("Профиль в разработке", comment: "Search placeholder title"))
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
Text(NSLocalizedString("Companion ID", comment: "Search placeholder companion title"))
.font(.subheadline)
.foregroundColor(.secondary)
Text(userId.uuidString)
.font(.body.monospaced())
.contextMenu {
Button(action: {
#if canImport(UIKit)
UIPasteboard.general.string = userId.uuidString
#endif
}) {
Label(NSLocalizedString("Скопировать", comment: "Search placeholder copy"), systemImage: "doc.on.doc")
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(UIColor.secondarySystemBackground))
)
Text(NSLocalizedString("Здесь появится информация о собеседнике и существующих чатах.", comment: "Search placeholder description"))
.font(.footnote)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Spacer()
}
.padding(.top, 40)
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(Color(UIColor.systemBackground))
}
}
private struct ChatRowView: View {
let chat: PrivateChatListItem
let currentUserId: String?
let messageLimitLine: Int
private let avatarSize: CGFloat = 52
private var title: String {
switch chat.chatType {
case .self:
return NSLocalizedString("Избранные сообщения", comment: "")
case .privateChat, .unknown:
if let custom = chat.chatData?.customName, !custom.isEmpty {
return custom
}
if let full = chat.chatData?.fullName, !full.isEmpty {
return full
}
if let login = chat.chatData?.login, !login.isEmpty {
return "@\(login)"
}
return NSLocalizedString("Неизвестный пользователь", comment: "")
}
}
private var officialFullName: String? {
guard let name = chat.chatData?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else {
return nil
}
return NSLocalizedString(name, comment: "")
}
private var loginDisplay: String? {
guard let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty else {
return nil
}
return "@\(login)"
}
private var isOfficial: Bool {
officialFullName != nil
}
private var isDeletedUser: Bool {
guard chat.chatType != .self else { return false }
let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines)
return login?.isEmpty ?? true
}
private var avatarBackgroundColor: Color {
if isDeletedUser {
return Color(.systemGray5)
}
return isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15)
}
private var avatarTextColor: Color {
if isDeletedUser {
return Color.accentColor
}
return isOfficial ? Color.white : Color.accentColor
}
private var messagePreview: String {
guard let message = chat.lastMessage else {
return NSLocalizedString("Нет сообщений", comment: "")
}
let body = messageBody(for: message)
guard let prefix = authorPrefix(for: message) else {
return body
}
return body.isEmpty ? prefix : "\(body)"
// return body.isEmpty ? prefix : "\(prefix): \(body)"
}
private func messageBody(for message: MessageItem) -> String {
if let content = trimmed(message.content), !content.isEmpty {
return content
}
if message.mediaLink != nil {
return NSLocalizedString("Вложение", comment: "")
}
return NSLocalizedString("Сообщение", comment: "")
}
private func authorPrefix(for message: MessageItem) -> String? {
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
if isCurrentUser {
return NSLocalizedString("Вы", comment: "")
}
let profile = message.senderData ?? chat.chatData
return displayName(for: profile)
}
private func displayName(for profile: ChatProfile?) -> String? {
guard let profile else { return nil }
let login = trimmed(profile.login)
let customName = trimmed(profile.customName)
let fullName = trimmed(profile.fullName)
let isOfficialProfile = fullName != nil
if isOfficialProfile, let login {
if let customName {
return "@\(login) (\(customName))"
}
return "@\(login)"
}
if let customName {
return customName
}
if let login {
return "@\(login)"
}
return fullName
}
private func trimmed(_ string: String?) -> String? {
guard let string = string?.trimmingCharacters(in: .whitespacesAndNewlines), !string.isEmpty else {
return nil
}
return string
}
private var timestamp: String? {
let date = chat.lastMessage?.createdAt ?? chat.createdAt
guard let date else { return nil }
return ChatRowView.formattedTimestamp(for: date)
}
private var initial: String {
let sourceName: String
if let full = officialFullName {
sourceName = full
} else if let custom = chat.chatData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
sourceName = custom
} else if let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
sourceName = login
} else {
sourceName = NSLocalizedString("Неизвестный пользователь", comment: "")
}
if let character = sourceName.first(where: { !$0.isWhitespace && $0 != "@" }) {
return String(character).uppercased()
}
return "?"
}
private var deletedUserSymbolName: String {
return "person.slash"
}
private var subtitleColor: Color {
chat.unreadCount > 0 ? .primary : .secondary
}
private var shouldShowReadStatus: Bool {
guard let message = chat.lastMessage else { return false }
let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false
return isCurrentUser
}
private var readStatusIconName: String {
guard let message = chat.lastMessage else { return "" }
return message.isViewed == true ? "checkmark.circle.fill" : "checkmark.circle"
}
private var readStatusColor: Color {
guard let message = chat.lastMessage else { return .secondary }
return message.isViewed == true ? Color.accentColor : Color.secondary
}
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(avatarBackgroundColor)
.frame(width: avatarSize, height: avatarSize)
.overlay(
Group {
if isDeletedUser {
Image(systemName: deletedUserSymbolName)
.symbolRenderingMode(.hierarchical)
.font(.system(size: avatarSize * 0.45, weight: .semibold))
.foregroundColor(avatarTextColor)
} else {
Text(initial)
.font(.system(size: avatarSize * 0.5, weight: .semibold))
.foregroundColor(avatarTextColor)
}
}
)
VStack(alignment: .leading, spacing: 4) {
if let officialName = officialFullName {
HStack(spacing: 6) {
if #available(iOS 16.0, *) {
Text(officialName)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
.strikethrough(isDeletedUser, color: Color.secondary)
} else {
Text(officialName)
.fontWeight(.semibold)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
Image(systemName: "checkmark.seal.fill")
.foregroundColor(Color.accentColor)
.font(.caption)
}
// if let login = loginDisplay {
// Text(login)
// .font(.footnote)
// .foregroundColor(.secondary)
// .lineLimit(1)
// .truncationMode(.tail)
// }
} else {
if #available(iOS 16.0, *) {
Text(title)
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
.strikethrough(isDeletedUser, color: Color.secondary)
} else {
Text(title)
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
.foregroundColor(.primary)
.lineLimit(1)
.truncationMode(.tail)
}
}
Text(messagePreview)
.font(.subheadline)
.foregroundColor(subtitleColor)
.lineLimit(messageLimitLine)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer()
VStack(alignment: .trailing, spacing: 6) {
if let timestamp {
HStack(spacing: 4) {
if shouldShowReadStatus {
Image(systemName: readStatusIconName)
.foregroundColor(readStatusColor)
.font(.caption2)
}
Text(timestamp)
.font(.caption)
.foregroundColor(.secondary)
}
}
if chat.unreadCount > 0 {
Text("\(chat.unreadCount)")
.font(.caption2.bold())
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule().fill(Color.accentColor)
)
}
}
}
.padding(.vertical, 8)
}
private static func formattedTimestamp(for date: Date) -> String {
let calendar = Calendar.current
let locale = Locale.current
let now = Date()
let startOfNow = calendar.startOfDay(for: now)
let startOfDate = calendar.startOfDay(for: date)
let diff = calendar.dateComponents([.day], from: startOfDate, to: startOfNow).day ?? 0
if diff <= 0 {
return timeString(for: date, locale: locale)
}
if diff == 1 {
return locale.identifier.lowercased().hasPrefix("ru") ? "Вчера" : "Yesterday"
}
if diff < 7 {
return weekdayFormatter(for: locale).string(from: date)
}
if diff < 365 {
return monthDayFormatter(for: locale).string(from: date)
}
return fullDateFormatter(for: locale).string(from: date)
}
private static func timeString(for date: Date, locale: Locale) -> String {
let timeFormat = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: locale) ?? ""
let is12Hour = timeFormat.contains("a") || timeFormat.contains("h") || timeFormat.contains("K")
let formatter = DateFormatter()
formatter.locale = locale
if is12Hour {
formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "h:mm a", options: 0, locale: locale) ?? "h:mm a"
} else {
formatter.dateFormat = "HH:mm"
}
return formatter.string(from: date)
}
private static func weekdayFormatter(for locale: Locale) -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = locale
formatter.setLocalizedDateFormatFromTemplate("EEE")
return formatter
}
private static func monthDayFormatter(for locale: Locale) -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = locale
formatter.setLocalizedDateFormatFromTemplate("MMM d")
return formatter
}
private static func fullDateFormatter(for locale: Locale) -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = locale
formatter.dateFormat = "dd.MM.yy"
return formatter
}
}
struct ChatsTab_Previews: PreviewProvider {
struct Wrapper: View {
@State private var progress: CGFloat = 1
@State private var searchText: String = ""
var body: some View {
ChatsTab(searchRevealProgress: $progress, searchText: $searchText)
.environmentObject(ThemeManager())
}
}
static var previews: some View {
Wrapper()
}
}
extension Notification.Name {
static let debugRefreshChats = Notification.Name("debugRefreshChats")
}