diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 7d39802..0b9bd09 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -2483,9 +2483,6 @@ }, "Пропустить" : { - }, - "Просмотр \"%1$@\" появится позже." : { - "comment" : "Contacts placeholder message" }, "Профиль" : { "comment" : "Message profile navigation title", diff --git a/yobble/Views/Tab/ContactsTab.swift b/yobble/Views/Tab/ContactsTab.swift index 51c90e5..21050f7 100644 --- a/yobble/Views/Tab/ContactsTab.swift +++ b/yobble/Views/Tab/ContactsTab.swift @@ -2,6 +2,7 @@ import SwiftUI import Foundation struct ContactsTab: View { + @ObservedObject private var loginViewModel: LoginViewModel @State private var contacts: [Contact] = [] @State private var isLoading = false @State private var loadError: String? @@ -9,16 +10,29 @@ struct ContactsTab: View { @State private var activeAlert: ContactsAlert? @State private var hasMore = true @State private var offset = 0 + @State private var creatingChatForContactId: UUID? + @State private var pendingChatItem: PrivateChatListItem? + @State private var isPendingChatActive = false private let contactsService = ContactsService() + private let chatService = ChatService() private let pageSize = 25 + private var currentUserId: String? { + let identifier = loginViewModel.userId + return identifier.isEmpty ? nil : identifier + } + + init(viewModel: LoginViewModel) { + self._loginViewModel = ObservedObject(wrappedValue: viewModel) + } + var body: some View { List { if isLoading && contacts.isEmpty { loadingState } - + if let loadError, contacts.isEmpty { errorState(loadError) } else if contacts.isEmpty { @@ -26,13 +40,13 @@ struct ContactsTab: View { } else { ForEach(Array(contacts.enumerated()), id: \.element.id) { index, contact in Button { - showContactPlaceholder(for: contact) + openChat(for: contact) } label: { - ContactRow(contact: contact) + ContactRow(contact: contact, isLoading: creatingChatForContactId == contact.id) .contentShape(Rectangle()) } .buttonStyle(.plain) -// .disabled(contact.isDeleted) + .disabled(contact.isDeleted || creatingChatForContactId == contact.id) .contextMenu { Button { handleContactAction(.edit, for: contact) @@ -106,6 +120,7 @@ struct ContactsTab: View { ) } } + .overlay(pendingChatNavigationLink) } private var loadingState: some View { @@ -219,26 +234,126 @@ struct ContactsTab: View { isLoading = false } - private func showContactPlaceholder(for contact: Contact) { - activeAlert = .info( - title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"), - message: String( - format: NSLocalizedString("Просмотр \"%1$@\" появится позже.", comment: "Contacts placeholder message"), - contact.displayName - ) - ) - } - private func handleContactAction(_ action: ContactAction, for contact: Contact) { activeAlert = .info( title: NSLocalizedString("Скоро", comment: "Contacts placeholder title"), message: action.placeholderMessage(for: contact) ) } + + 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 func openChat(for contact: Contact) { + guard creatingChatForContactId == nil else { return } + guard !contact.isDeleted else { return } + + creatingChatForContactId = contact.id + + chatService.createOrFindPrivateChat(targetUserId: contact.id.uuidString) { result in + DispatchQueue.main.async { + creatingChatForContactId = nil + + switch result { + case .success(let data): + let chatItem = PrivateChatListItem( + chatId: data.chatId, + chatType: data.chatType, + chatData: chatProfile(for: contact), + lastMessage: nil, + createdAt: nil, + unreadCount: 0 + ) + pendingChatItem = chatItem + isPendingChatActive = true + case .failure(let error): + activeAlert = .error(message: friendlyChatCreationMessage(for: error)) + } + } + } + } + + private func chatProfile(for contact: Contact) -> ChatProfile { + ChatProfile( + userId: contact.id.uuidString, + login: contact.login, + fullName: contact.fullName, + customName: contact.customName, + createdAt: contact.createdAt, + isOfficial: false + ) + } + + private 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") + } } private struct ContactRow: View { let contact: Contact + let isLoading: Bool + + init(contact: Contact, isLoading: Bool = false) { + self.contact = contact + self.isLoading = isLoading + } var body: some View { HStack(alignment: .top, spacing: 10) { @@ -289,6 +404,12 @@ private struct ContactRow: View { } } .frame(maxWidth: .infinity, alignment: .leading) + + if isLoading { + ProgressView() + .scaleEffect(0.8) + .padding(.top, 4) + } } .padding(.vertical, 6) } diff --git a/yobble/Views/Tab/MainView.swift b/yobble/Views/Tab/MainView.swift index 2ef2049..96a1f0f 100644 --- a/yobble/Views/Tab/MainView.swift +++ b/yobble/Views/Tab/MainView.swift @@ -69,7 +69,7 @@ struct MainView: View { .opacity(selectedTab == 2 ? 1 : 0) .allowsHitTesting(selectedTab == 2) - ContactsTab() + ContactsTab(viewModel: viewModel) .opacity(selectedTab == 4 ? 1 : 0) SettingsView(viewModel: viewModel)