// // ChatsTab.swift // VolnahubApp // // Created by cheykrym on 09/06/2025. // import SwiftUI #if canImport(UIKit) import UIKit #endif struct ChatsTab: View { @ObservedObject private var loginViewModel: LoginViewModel @Binding private var pendingDeepLink: ChatDeepLink? @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? = 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 private var currentUserId: String? { let userId = loginViewModel.userId return userId.isEmpty ? nil : userId } init( loginViewModel: LoginViewModel, pendingDeepLink: Binding, searchRevealProgress: Binding, searchText: Binding ) { self._loginViewModel = ObservedObject(wrappedValue: loginViewModel) self._pendingDeepLink = pendingDeepLink 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 if loginViewModel.chatLoadingState != .loading { loginViewModel.chatLoadingState = .loading } viewModel.refresh() } .onReceive(NotificationCenter.default.publisher(for: .chatsShouldRefresh)) { _ in if loginViewModel.chatLoadingState != .loading { loginViewModel.chatLoadingState = .loading } 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 } .onChange(of: pendingDeepLink?.id) { _ in guard let link = pendingDeepLink else { return } handle(chatDeepLink: link) DispatchQueue.main.async { pendingDeepLink = 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: triggerChatsReload) { 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: triggerChatsReload) { 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: triggerChatsReload) { 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) } private func triggerChatsReload() { if loginViewModel.chatLoadingState != .loading { loginViewModel.chatLoadingState = .loading } viewModel.loadInitialChats(force: true) } @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 handle(chatDeepLink: ChatDeepLink) { dismissKeyboard() if !searchText.isEmpty { searchText = "" } if searchRevealProgress > 0 { withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { searchRevealProgress = 0 } } let existingChat = viewModel.chats.first(where: { $0.chatId == chatDeepLink.chatId }) pendingChatItem = existingChat ?? makeChatItem(from: chatDeepLink) selectedChatId = chatDeepLink.chatId isPendingChatActive = true if existingChat == nil { if loginViewModel.chatLoadingState != .loading { loginViewModel.chatLoadingState = .loading } viewModel.refresh() } } func makeChatItem(from deepLink: ChatDeepLink) -> PrivateChatListItem { let profile = deepLink.chatProfile ?? deepLink.message?.senderData let lastMessage = deepLink.message let createdAt = deepLink.message?.createdAt return PrivateChatListItem( chatId: deepLink.chatId, chatType: .privateChat, chatData: profile, lastMessage: lastMessage, createdAt: createdAt, unreadCount: 0 ) } 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 = user.login ?? profile?.login let fullName = profile?.fullName let customName = user.preferredCustomName ?? profile?.customName return ChatProfile( userId: user.userId.uuidString, login: login, fullName: fullName, customName: customName, bio: profile?.bio, lastSeen: profile?.lastSeen, createdAt: user.createdAt ?? profile?.createdAt, isOfficial: user.isOfficial ) } 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 loginDisplay: String? { guard let login = chat.chatData?.login?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty else { return nil } return "@\(login)" } private var isOfficial: Bool { chat.chatData?.isOfficial ?? false } private var officialDisplayName: String? { guard isOfficial else { return nil } if let customName = chat.chatData?.customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty { return customName } if let name = chat.chatData?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { return NSLocalizedString(name, comment: "") } return loginDisplay } 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 = profile.isOfficial if let customName { return customName } if isOfficialProfile { if let fullName { return fullName } if let login { return "@\(login)" } } 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 custom = chat.chatData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { sourceName = custom } else if let displayName = officialDisplayName { sourceName = displayName } else if let full = chat.chatData?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), !full.isEmpty { sourceName = full } 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 = officialDisplayName { 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 = "" @StateObject private var loginViewModel = LoginViewModel() @State private var deepLink: ChatDeepLink? var body: some View { ChatsTab( loginViewModel: loginViewModel, pendingDeepLink: $deepLink, searchRevealProgress: $progress, searchText: $searchText ) .environmentObject(ThemeManager()) } } static var previews: some View { Wrapper() } } extension Notification.Name { static let debugRefreshChats = Notification.Name("debugRefreshChats") static let chatsShouldRefresh = Notification.Name("chatsShouldRefresh") static let chatsReloadCompleted = Notification.Name("chatsReloadCompleted") }