712 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			712 lines
		
	
	
		
			24 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
//
 | 
						||
//  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
 | 
						||
    @StateObject private var viewModel = PrivateChatsViewModel()
 | 
						||
    @State private var selectedChatId: String?
 | 
						||
    @State private var searchDragStartProgress: CGFloat = 0
 | 
						||
    @State private var isSearchGestureActive: 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()
 | 
						||
            }
 | 
						||
            .onReceive(NotificationCenter.default.publisher(for: .debugRefreshChats)) { _ in
 | 
						||
                viewModel.refresh()
 | 
						||
            }
 | 
						||
    }
 | 
						||
 | 
						||
    @ViewBuilder
 | 
						||
    private var content: some View {
 | 
						||
        if viewModel.isInitialLoading && viewModel.chats.isEmpty {
 | 
						||
            loadingState
 | 
						||
        } else if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
 | 
						||
            errorState(message: message)
 | 
						||
        } else if viewModel.chats.isEmpty {
 | 
						||
            emptyState
 | 
						||
        } 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) {
 | 
						||
                    Text(NSLocalizedString("Ничего не найдено", comment: "Global search placeholder"))
 | 
						||
                        .font(.footnote)
 | 
						||
                        .foregroundColor(.secondary)
 | 
						||
                        .padding(.vertical, 12)
 | 
						||
                        .frame(maxWidth: .infinity, alignment: .leading)
 | 
						||
                        .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
 | 
						||
                        .listRowSeparator(.hidden)
 | 
						||
                }
 | 
						||
            } 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"))
 | 
						||
    }
 | 
						||
 | 
						||
    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)
 | 
						||
    }
 | 
						||
 | 
						||
    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: ChatPlaceholderView(chat: chat),
 | 
						||
                tag: chat.chatId,
 | 
						||
                selection: $selectedChatId
 | 
						||
            ) {
 | 
						||
                EmptyView()
 | 
						||
            }
 | 
						||
            .hidden()
 | 
						||
        )
 | 
						||
        .onAppear {
 | 
						||
            guard !isSearching else { return }
 | 
						||
            viewModel.loadMoreIfNeeded(currentItem: chat)
 | 
						||
        }
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
private extension ChatsTab {
 | 
						||
    func dismissKeyboard() {
 | 
						||
#if canImport(UIKit)
 | 
						||
        UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
 | 
						||
#endif
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
private struct ScrollDismissesKeyboardModifier: ViewModifier {
 | 
						||
    func body(content: Content) -> some View {
 | 
						||
        if #available(iOS 16.0, *) {
 | 
						||
            content.scrollDismissesKeyboard(.interactively)
 | 
						||
        } else {
 | 
						||
            content
 | 
						||
        }
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
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 avatarBackgroundColor: Color {
 | 
						||
        isOfficial ? Color.accentColor.opacity(0.85) : Color.accentColor.opacity(0.15)
 | 
						||
    }
 | 
						||
 | 
						||
    private var avatarTextColor: Color {
 | 
						||
        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 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(
 | 
						||
                    Text(initial)
 | 
						||
                        .font(.headline)
 | 
						||
                        .foregroundColor(avatarTextColor)
 | 
						||
                )
 | 
						||
 | 
						||
            VStack(alignment: .leading, spacing: 4) {
 | 
						||
                if let officialName = officialFullName {
 | 
						||
                    HStack(spacing: 6) {
 | 
						||
                        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 {
 | 
						||
                    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()
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
private struct ChatPlaceholderView: View {
 | 
						||
    let chat: PrivateChatListItem
 | 
						||
 | 
						||
    private var companionId: String {
 | 
						||
        if let profileId = chat.chatData?.userId {
 | 
						||
            return profileId
 | 
						||
        }
 | 
						||
        if let senderId = chat.lastMessage?.senderId {
 | 
						||
            return senderId
 | 
						||
        }
 | 
						||
        return NSLocalizedString("Неизвестный", comment: "")
 | 
						||
    }
 | 
						||
 | 
						||
    var body: some View {
 | 
						||
        VStack(spacing: 16) {
 | 
						||
            Text(NSLocalizedString("Экран чата в разработке", comment: ""))
 | 
						||
                .font(.headline)
 | 
						||
 | 
						||
            VStack(alignment: .leading, spacing: 8) {
 | 
						||
                HStack {
 | 
						||
                    Text("Chat ID:")
 | 
						||
                        .font(.subheadline)
 | 
						||
                        .foregroundColor(.secondary)
 | 
						||
                    Text(chat.chatId)
 | 
						||
                        .font(.body.monospaced())
 | 
						||
                }
 | 
						||
 | 
						||
                if companionId != NSLocalizedString("Неизвестный", comment: "") {
 | 
						||
                    HStack {
 | 
						||
                        Text("Companion ID:")
 | 
						||
                            .font(.subheadline)
 | 
						||
                            .foregroundColor(.secondary)
 | 
						||
                        Text(companionId)
 | 
						||
                            .font(.body.monospaced())
 | 
						||
                    }
 | 
						||
                }
 | 
						||
            }
 | 
						||
            .frame(maxWidth: .infinity, alignment: .leading)
 | 
						||
 | 
						||
            Spacer()
 | 
						||
        }
 | 
						||
        .padding()
 | 
						||
        .navigationTitle(title)
 | 
						||
        .navigationBarTitleDisplayMode(.inline)
 | 
						||
    }
 | 
						||
 | 
						||
    private var title: String {
 | 
						||
        if let full = chat.chatData?.fullName, !full.isEmpty {
 | 
						||
            return full
 | 
						||
        }
 | 
						||
        if let custom = chat.chatData?.customName, !custom.isEmpty {
 | 
						||
            return custom
 | 
						||
        }
 | 
						||
        if let login = chat.chatData?.login, !login.isEmpty {
 | 
						||
            return "@\(login)"
 | 
						||
        }
 | 
						||
        return NSLocalizedString("Чат", comment: "")
 | 
						||
    }
 | 
						||
}
 | 
						||
 | 
						||
extension Notification.Name {
 | 
						||
    static let debugRefreshChats = Notification.Name("debugRefreshChats")
 | 
						||
}
 |