diff --git a/yobble.xcodeproj/project.pbxproj b/yobble.xcodeproj/project.pbxproj index 856e416..a3e6849 100644 --- a/yobble.xcodeproj/project.pbxproj +++ b/yobble.xcodeproj/project.pbxproj @@ -395,7 +395,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = V22H44W47J; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -435,7 +435,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = yobble/yobble.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = V22H44W47J; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/yobble/Network/ChatModels.swift b/yobble/Network/ChatModels.swift index 5a9868e..0e9ef85 100644 --- a/yobble/Network/ChatModels.swift +++ b/yobble/Network/ChatModels.swift @@ -10,6 +10,28 @@ struct PrivateChatHistoryData: Decodable { let hasMore: Bool } +struct PrivateChatCreateData: Decodable { + let chatId: String + let chatType: PrivateChatListItem.ChatType + let status: String + let message: String + + private enum CodingKeys: String, CodingKey { + case chatId + case chatType + case status + case message + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.chatId = try container.decodeFlexibleString(forKey: .chatId) + self.chatType = try container.decode(PrivateChatListItem.ChatType.self, forKey: .chatType) + self.status = try container.decode(String.self, forKey: .status) + self.message = try container.decode(String.self, forKey: .message) + } +} + struct PrivateMessageSendData: Decodable { let messageId: String let chatId: String @@ -180,6 +202,34 @@ struct ChatProfile: Decodable { } } +extension ChatProfile { + init( + userId: String, + login: String?, + fullName: String?, + customName: String?, + bio: String? = nil, + lastSeen: Int? = nil, + createdAt: Date? = nil, + stories: [JSONValue] = [], + permissions: ChatPermissions? = nil, + profilePermissions: ChatProfilePermissions? = nil, + relationship: RelationshipStatus? = nil + ) { + self.userId = userId + self.login = login + self.fullName = fullName + self.customName = customName + self.bio = bio + self.lastSeen = lastSeen + self.createdAt = createdAt + self.stories = stories + self.permissions = permissions + self.profilePermissions = profilePermissions + self.relationship = relationship + } +} + struct ChatPermissions: Decodable { let youCanSendMessage: Bool let youCanPublicInvitePermission: Bool diff --git a/yobble/Network/ChatService.swift b/yobble/Network/ChatService.swift index 580dcde..6553d55 100644 --- a/yobble/Network/ChatService.swift +++ b/yobble/Network/ChatService.swift @@ -111,6 +111,43 @@ final class ChatService { } } + func createOrFindPrivateChat( + targetUserId: String, + completion: @escaping (Result) -> Void + ) { + let query: [String: String?] = [ + "target_user_id": targetUserId + ] + + client.request( + path: "/v1/chat/private/create", + method: .post, + query: query, + requiresAuth: true + ) { [decoder] result in + switch result { + case .success(let response): + do { + let apiResponse = try decoder.decode(APIResponse.self, from: response.data) + guard apiResponse.status == "fine" else { + let message = apiResponse.detail ?? NSLocalizedString("Не удалось создать чат.", comment: "") + completion(.failure(ChatServiceError.unexpectedStatus(message))) + return + } + completion(.success(apiResponse.data)) + } catch { + let debugMessage = Self.describeDecodingError(error: error, data: response.data) + if AppConfig.DEBUG { + print("[ChatService] create private chat decode failed: \(debugMessage)") + } + completion(.failure(ChatServiceError.decoding(debugDescription: debugMessage))) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + func sendPrivateMessage( chatId: String, content: String, diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index c468373..aa378b4 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -939,6 +939,12 @@ } } }, + "Не удалось открыть чат" : { + "comment" : "Chat creation error title" + }, + "Не удалось открыть чат." : { + "comment" : "Chat creation fallback" + }, "Не удалось отправить сообщение." : { }, @@ -954,6 +960,9 @@ } } } + }, + "Не удалось создать чат." : { + }, "Не удалось сохранить изменения профиля." : { "comment" : "Profile update unexpected status" @@ -1303,6 +1312,7 @@ } }, "Ошибка сервера (%@)." : { + "comment" : "Chat creation server status", "localizations" : { "en" : { "stringUnit" : { @@ -1323,6 +1333,7 @@ } }, "Ошибка сети: %@" : { + "comment" : "Chat creation network error", "localizations" : { "en" : { "stringUnit" : { @@ -1333,7 +1344,7 @@ } }, "Ошибка соединения с сервером." : { - "comment" : "Search error connection", + "comment" : "Chat creation connection\nSearch error connection", "localizations" : { "en" : { "stringUnit" : { @@ -1565,6 +1576,9 @@ } } }, + "Понятно" : { + "comment" : "Chat creation error acknowledgment" + }, "Попробуйте изменить запрос поиска." : { }, @@ -1715,6 +1729,9 @@ "Произошла неизвестная ошибка." : { "comment" : "Search unknown error" }, + "Произошла неизвестная ошибка. Попробуйте позже." : { + "comment" : "Chat creation unknown error" + }, "Произошла ошибка." : { "localizations" : { "en" : { @@ -1911,7 +1928,7 @@ } }, "Сессия истекла. Войдите снова." : { - "comment" : "Search error unauthorized", + "comment" : "Chat creation unauthorized\nSearch error unauthorized", "localizations" : { "en" : { "stringUnit" : { diff --git a/yobble/Views/Tab/ChatsTab.swift b/yobble/Views/Tab/ChatsTab.swift index ab6140f..96fb8fd 100644 --- a/yobble/Views/Tab/ChatsTab.swift +++ b/yobble/Views/Tab/ChatsTab.swift @@ -15,6 +15,7 @@ struct ChatsTab: View { @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? @@ -24,7 +25,11 @@ struct ChatsTab: View { @State private var isGlobalSearchLoading: Bool = false @State private var globalSearchError: String? @State private var globalSearchTask: Task? = nil - @State private var selectedSearchUserId: UUID? + @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 @@ -53,6 +58,23 @@ struct ChatsTab: View { } } } + .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 @@ -69,80 +91,111 @@ struct ChatsTab: View { } private var chatList: some View { - List { + ZStack { + List { // VStack(spacing: 0) { // searchBar // .padding(.horizontal, 16) // .padding(.vertical, 8) // } // .background(Color(UIColor.systemBackground)) - - if let message = viewModel.errorMessage { - Section { - HStack(alignment: .top, spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - Text(message) - .font(.subheadline) - .foregroundColor(.orange) - Spacer(minLength: 0) - Button(action: { viewModel.refresh() }) { - Text(NSLocalizedString("Обновить", comment: "")) + + 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) + } } } - .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) + Section(header: globalSearchHeader) { + globalSearchContent + } + } else { + if let message = viewModel.errorMessage, viewModel.chats.isEmpty { + errorState(message: message) + } else if viewModel.chats.isEmpty { + emptyState } else { - ForEach(localSearchResults) { chat in + + ForEach(viewModel.chats) { 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 + 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() + .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)) // } -// .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 { @@ -404,7 +457,7 @@ struct ChatsTab: View { private func globalSearchRow(for user: UserSearchResult) -> some View { Button { - selectedSearchUserId = user.userId + openPrivateChat(for: user) } label: { HStack(spacing: 12) { Circle() @@ -440,21 +493,17 @@ struct ChatsTab: View { } } .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)) - .background( - NavigationLink( - destination: SearchResultPlaceholderView(userId: user.userId), - tag: user.userId, - selection: $selectedSearchUserId - ) { - EmptyView() - } - .hidden() - ) } } @@ -480,7 +529,6 @@ private extension ChatsTab { globalSearchResults = [] globalSearchError = nil isGlobalSearchLoading = false - selectedSearchUserId = nil return } @@ -499,7 +547,6 @@ private extension ChatsTab { isGlobalSearchLoading = false globalSearchError = nil globalSearchTask = nil - selectedSearchUserId = nil } } catch is CancellationError { // Ignore cancellation @@ -510,7 +557,6 @@ private extension ChatsTab { isGlobalSearchLoading = false globalSearchError = friendlyErrorMessage(for: error) globalSearchTask = nil - selectedSearchUserId = nil } } } @@ -522,7 +568,6 @@ private extension ChatsTab { globalSearchResults = [] globalSearchError = nil isGlobalSearchLoading = false - selectedSearchUserId = nil } func secondaryLine(for user: UserSearchResult) -> String? { @@ -552,6 +597,91 @@ private extension ChatsTab { return nil } + func openPrivateChat(for user: UserSearchResult) { + guard creatingChatForUserId == nil else { return } + + dismissKeyboard() + creatingChatForUserId = user.userId + chatCreationError = nil + + chatService.createOrFindPrivateChat(targetUserId: user.userId.uuidString) { result in + creatingChatForUserId = nil + + switch result { + case .success(let data): + let chatItem = PrivateChatListItem( + chatId: data.chatId, + chatType: data.chatType, + chatData: chatProfile(from: user), + lastMessage: nil, + createdAt: nil, + unreadCount: 0 + ) + + pendingChatItem = chatItem + isPendingChatActive = true + viewModel.refresh() + case .failure(let error): + chatCreationError = friendlyChatCreationMessage(for: error) + isChatCreationAlertPresented = true + } + } + } + + func chatProfile(from user: UserSearchResult) -> ChatProfile { + let profile = user.profile + let login = profile?.login ?? user.login + let fullName = user.officialFullName ?? user.fullName ?? profile?.fullName + let customName = user.preferredCustomName ?? user.customName ?? profile?.customName + + return ChatProfile( + userId: user.userId.uuidString, + login: login, + fullName: fullName, + customName: customName, + bio: profile?.bio, + lastSeen: profile?.lastSeen, + createdAt: profile?.createdAt + ) + } + + func friendlyChatCreationMessage(for error: Error) -> String { + if let chatError = error as? ChatServiceError { + return chatError.errorDescription ?? NSLocalizedString("Не удалось открыть чат.", comment: "Chat creation fallback") + } + + if let networkError = error as? NetworkError { + switch networkError { + case .unauthorized: + return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "Chat creation unauthorized") + case .invalidURL, .noResponse: + return NSLocalizedString("Ошибка соединения с сервером.", comment: "Chat creation connection") + case .network(let underlying): + return String(format: NSLocalizedString("Ошибка сети: %@", comment: "Chat creation network error"), underlying.localizedDescription) + case .server(let statusCode, let data): + if let data { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + if let payload = try? decoder.decode(ErrorResponse.self, from: data) { + if let detail = payload.detail?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty { + return detail + } + if let message = payload.data?.message?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty { + return message + } + } + + if let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty { + return raw + } + } + return String(format: NSLocalizedString("Ошибка сервера (%@).", comment: "Chat creation server status"), "\(statusCode)") + } + } + + return NSLocalizedString("Произошла неизвестная ошибка. Попробуйте позже.", comment: "Chat creation unknown error") + } + func friendlyErrorMessage(for error: Error) -> String { if let searchError = error as? SearchServiceError {