// // 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() @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 selectedSearchUserId: UUID? private let searchRevealDistance: CGFloat = 90 init(currentUserId: String? = nil, searchRevealProgress: Binding, searchText: Binding) { 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) } .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 { 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)) // } } 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 searchRevealProgress = max(0, min(1, newProgress)) } .onEnded { _ in guard isSearchGestureActive else { return } isSearchGestureActive = false 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 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) .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() ) .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 { selectedSearchUserId = user.userId } 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) } .padding(.vertical, 8) } .buttonStyle(.plain) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) .background( NavigationLink( destination: SearchResultPlaceholderView(userId: user.userId), tag: user.userId, selection: $selectedSearchUserId ) { EmptyView() } .hidden() ) } } 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 selectedSearchUserId = nil 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 selectedSearchUserId = nil } } catch is CancellationError { // Ignore cancellation } catch { guard !Task.isCancelled else { return } await MainActor.run { globalSearchResults = [] isGlobalSearchLoading = false globalSearchError = friendlyErrorMessage(for: error) globalSearchTask = nil selectedSearchUserId = nil } } } } func resetGlobalSearch() { globalSearchTask?.cancel() globalSearchTask = nil globalSearchResults = [] globalSearchError = nil isGlobalSearchLoading = false selectedSearchUserId = nil } 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 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? 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: 44, height: 44) .overlay( Group { if isDeletedUser { Image(systemName: deletedUserSymbolName) .symbolRenderingMode(.hierarchical) .font(.system(size: 20, weight: .semibold)) .foregroundColor(avatarTextColor) } else { Text(initial) .font(.headline) .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(2) .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") }