From 207187a4392af41c2c340f4ccecb9b9f9b438f93 Mon Sep 17 00:00:00 2001 From: cheykrym Date: Wed, 8 Oct 2025 05:55:32 +0300 Subject: [PATCH] add history load --- yobble/Network/ChatModels.swift | 5 + yobble/Network/ChatService.swift | 45 ++++ yobble/Resources/Localizable.xcstrings | 27 +-- yobble/ViewModels/PrivateChatViewModel.swift | 128 +++++++++++ yobble/Views/Chat/PrivateChatView.swift | 211 +++++++++++++++++++ yobble/Views/Tab/ChatsTab.swift | 62 +----- 6 files changed, 405 insertions(+), 73 deletions(-) create mode 100644 yobble/ViewModels/PrivateChatViewModel.swift create mode 100644 yobble/Views/Chat/PrivateChatView.swift diff --git a/yobble/Network/ChatModels.swift b/yobble/Network/ChatModels.swift index 12650a7..4e2d397 100644 --- a/yobble/Network/ChatModels.swift +++ b/yobble/Network/ChatModels.swift @@ -5,6 +5,11 @@ struct PrivateChatListData: Decodable { let hasMore: Bool } +struct PrivateChatHistoryData: Decodable { + let items: [MessageItem] + let hasMore: Bool +} + struct PrivateChatListItem: Decodable, Identifiable { enum ChatType: String, Decodable { case `self` diff --git a/yobble/Network/ChatService.swift b/yobble/Network/ChatService.swift index cd3e39b..8a83c3a 100644 --- a/yobble/Network/ChatService.swift +++ b/yobble/Network/ChatService.swift @@ -66,6 +66,51 @@ final class ChatService { } } + func fetchPrivateChatHistory( + chatId: String, + beforeMessageId: String?, + limit: Int, + completion: @escaping (Result) -> Void + ) { + var query: [String: String?] = [ + "chat_id": chatId, + "limit": String(limit) + ] + + if let beforeMessageId, + !beforeMessageId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + query["before_message_id"] = beforeMessageId + } + + client.request( + path: "/v1/chat/private/history", + method: .get, + 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] decode private chat history failed: \(debugMessage)") + } + completion(.failure(ChatServiceError.decoding(debugDescription: debugMessage))) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + private static func decodeDate(from decoder: Decoder) throws -> Date { let container = try decoder.singleValueContainer() let string = try container.decode(String.self) diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index b018fb0..f03a539 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "ru", "strings" : { + "(без текста)" : { + + }, "@%@" : { "localizations" : { "en" : { @@ -57,15 +60,9 @@ } } } - }, - "Chat ID:" : { - }, "Companion ID" : { "comment" : "Search placeholder companion title" - }, - "Companion ID:" : { - }, "DEBUG UPDATE" : { "localizations" : { @@ -210,6 +207,9 @@ } } } + }, + "В чате пока нет сообщений." : { + }, "Ваш e-mail" : { "comment" : "feedback: email placeholder", @@ -414,6 +414,9 @@ }, "Заглушка: Хранилище данных" : { + }, + "Загружаем ранние сообщения…" : { + }, "Загружаем чаты…" : { "localizations" : { @@ -424,6 +427,9 @@ } } } + }, + "Загрузка сообщений…" : { + }, "Загрузка..." : { "localizations" : { @@ -872,6 +878,9 @@ }, "Не удалось выполнить поиск." : { "comment" : "Search error fallback\nSearch service decoding error" + }, + "Не удалось загрузить историю чата." : { + }, "Не удалось загрузить профиль." : { "comment" : "Profile service decoding error\nProfile unexpected status" @@ -1048,9 +1057,6 @@ } } } - }, - "Неизвестный" : { - }, "Неизвестный пользователь" : { "localizations" : { @@ -2232,9 +2238,6 @@ } } } - }, - "Экран чата в разработке" : { - }, "Язык" : { "localizations" : { diff --git a/yobble/ViewModels/PrivateChatViewModel.swift b/yobble/ViewModels/PrivateChatViewModel.swift new file mode 100644 index 0000000..bcccc65 --- /dev/null +++ b/yobble/ViewModels/PrivateChatViewModel.swift @@ -0,0 +1,128 @@ +import Foundation + +@MainActor +final class PrivateChatViewModel: ObservableObject { + @Published private(set) var messages: [MessageItem] = [] + @Published private(set) var isInitialLoading: Bool = false + @Published private(set) var isLoadingMore: Bool = false + @Published var errorMessage: String? + + private let chatService: ChatService + private let chatId: String + private let pageSize: Int + private var hasMore: Bool = true + private var didLoadInitially: Bool = false + + init(chatId: String, chatService: ChatService = ChatService(), pageSize: Int = 30) { + self.chatId = chatId + self.chatService = chatService + self.pageSize = pageSize + } + + func loadInitialHistory(force: Bool = false) { + if !force && didLoadInitially { return } + guard !isInitialLoading else { return } + + isInitialLoading = true + errorMessage = nil + hasMore = true + + chatService.fetchPrivateChatHistory(chatId: chatId, beforeMessageId: nil, limit: pageSize) { [weak self] result in + guard let self else { return } + + switch result { + case .success(let data): + self.hasMore = data.hasMore + self.messages = Self.merge(existing: [], newMessages: data.items) + self.didLoadInitially = true + case .failure(let error): + self.errorMessage = self.message(for: error) + } + + self.isInitialLoading = false + } + } + + func refresh() { + didLoadInitially = false + loadInitialHistory(force: true) + } + + func loadMoreIfNeeded(for message: MessageItem) { + guard didLoadInitially, !isInitialLoading, hasMore, !isLoadingMore else { return } + guard let first = messages.first, first.id == message.id else { return } + + isLoadingMore = true + + chatService.fetchPrivateChatHistory(chatId: chatId, beforeMessageId: message.id, limit: pageSize) { [weak self] result in + guard let self else { return } + + switch result { + case .success(let data): + self.hasMore = data.hasMore + if !data.items.isEmpty { + self.messages = Self.merge(existing: self.messages, newMessages: data.items) + } + case .failure(let error): + self.errorMessage = self.message(for: error) + } + + self.isLoadingMore = false + } + } + + private func message(for error: Error) -> String { + if let chatError = error as? ChatServiceError { + return chatError.errorDescription ?? NSLocalizedString("Не удалось загрузить историю чата.", comment: "") + } + + if let networkError = error as? NetworkError { + switch networkError { + case .unauthorized: + return NSLocalizedString("Сессия истекла. Войдите снова.", comment: "") + case .invalidURL, .noResponse: + return NSLocalizedString("Ошибка соединения с сервером.", comment: "") + case .network(let underlying): + return String(format: NSLocalizedString("Ошибка сети: %@", comment: ""), underlying.localizedDescription) + case .server(let statusCode, _): + return String(format: NSLocalizedString("Ошибка сервера (%@).", comment: ""), "\(statusCode)") + } + } + + return NSLocalizedString("Неизвестная ошибка. Попробуйте позже.", comment: "") + } + + private static func merge(existing: [MessageItem], newMessages: [MessageItem]) -> [MessageItem] { + var combined: [MessageItem] = [] + combined.reserveCapacity(existing.count + newMessages.count) + + var seen: Set = [] + + for message in newMessages { + if seen.insert(message.id).inserted { + combined.append(message) + } + } + + for message in existing { + if seen.insert(message.id).inserted { + combined.append(message) + } + } + + combined.sort(by: compare) + return combined + } + + private static func compare(lhs: MessageItem, rhs: MessageItem) -> Bool { + if let lhsDate = lhs.createdAt, let rhsDate = rhs.createdAt, lhsDate != rhsDate { + return lhsDate < rhsDate + } + + if let lhsId = Int(lhs.messageId), let rhsId = Int(rhs.messageId), lhsId != rhsId { + return lhsId < rhsId + } + + return lhs.messageId < rhs.messageId + } +} diff --git a/yobble/Views/Chat/PrivateChatView.swift b/yobble/Views/Chat/PrivateChatView.swift new file mode 100644 index 0000000..49ceb49 --- /dev/null +++ b/yobble/Views/Chat/PrivateChatView.swift @@ -0,0 +1,211 @@ +import SwiftUI + +struct PrivateChatView: View { + let chat: PrivateChatListItem + let currentUserId: String? + + @StateObject private var viewModel: PrivateChatViewModel + @State private var hasPositionedToBottom: Bool = false + + init(chat: PrivateChatListItem, currentUserId: String?) { + self.chat = chat + self.currentUserId = currentUserId + _viewModel = StateObject(wrappedValue: PrivateChatViewModel(chatId: chat.chatId)) + } + + var body: some View { + ScrollViewReader { proxy in + content + .onChange(of: viewModel.messages.count) { _ in + guard !viewModel.isLoadingMore, + let lastId = viewModel.messages.last?.id else { return } + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(lastId, anchor: .bottom) + } + hasPositionedToBottom = true + } + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .task { + viewModel.loadInitialHistory() + } + .onChange(of: viewModel.isInitialLoading) { isLoading in + if isLoading { + hasPositionedToBottom = false + } + } + } + + @ViewBuilder + private var content: some View { + if viewModel.isInitialLoading && viewModel.messages.isEmpty { + ProgressView(NSLocalizedString("Загрузка сообщений…", comment: "")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = viewModel.errorMessage, viewModel.messages.isEmpty { + errorView(message: error) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + if viewModel.isLoadingMore { + loadingMoreView + } else if viewModel.messages.isEmpty { + emptyState + } + + ForEach(viewModel.messages) { message in + messageRow(for: message) + .id(message.id) + .onAppear { + guard hasPositionedToBottom else { return } + viewModel.loadMoreIfNeeded(for: message) + } + } + + if let message = viewModel.errorMessage, + !message.isEmpty, + !viewModel.messages.isEmpty { + errorBanner(message: message) + } + } + .padding(.vertical, 12) + } + .refreshable { + viewModel.refresh() + } + } + } + + private var emptyState: some View { + Text(NSLocalizedString("В чате пока нет сообщений.", comment: "")) + .font(.footnote) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 32) + } + + private var loadingMoreView: some View { + HStack { + ProgressView() + Text(NSLocalizedString("Загружаем ранние сообщения…", comment: "")) + .font(.caption) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + private func errorView(message: String) -> some View { + VStack(spacing: 12) { + Text(message) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + + Button(action: { viewModel.refresh() }) { + Text(NSLocalizedString("Повторить", comment: "")) + .font(.body) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func messageRow(for message: MessageItem) -> some View { + let isCurrentUser = currentUserId.map { $0 == message.senderId } ?? false + return HStack { + if isCurrentUser { Spacer(minLength: 32) } + + VStack(alignment: isCurrentUser ? .trailing : .leading, spacing: 6) { + if !isCurrentUser { + Text(senderName(for: message)) + .font(.caption) + .foregroundColor(.secondary) + } + + Text(contentText(for: message)) + .font(.body) + .foregroundColor(isCurrentUser ? .white : .primary) + .frame(maxWidth: .infinity, alignment: isCurrentUser ? .trailing : .leading) + + Text(timestamp(for: message)) + .font(.caption2) + .foregroundColor(isCurrentUser ? Color.white.opacity(0.8) : .secondary) + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(isCurrentUser ? Color.accentColor : Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + + if !isCurrentUser { Spacer(minLength: 32) } + } + .padding(.horizontal, 16) + } + + private func senderName(for message: MessageItem) -> String { + if let full = message.senderData?.fullName, !full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return full + } + if let custom = message.senderData?.customName, !custom.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return custom + } + if let login = message.senderData?.login, !login.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "@\(login)" + } + return message.senderId + } + + private func timestamp(for message: MessageItem) -> String { + guard let date = message.createdAt else { + return "" + } + return Self.timeFormatter.string(from: date) + } + + private func contentText(for message: MessageItem) -> String { + guard let content = message.content?.trimmingCharacters(in: .whitespacesAndNewlines), !content.isEmpty else { + return NSLocalizedString("(без текста)", comment: "") + } + return content + } + + private func errorBanner(message: String) -> some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text(message) + .font(.footnote) + .foregroundColor(.primary) + } + .padding() + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .padding(.horizontal, 16) + .padding(.top, 8) + } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() + + 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: "") + } +} + +// MARK: - Preview + +// Previews intentionally omitted - MessageItem has custom decoding-only initializer. diff --git a/yobble/Views/Tab/ChatsTab.swift b/yobble/Views/Tab/ChatsTab.swift index b26ba4a..7837b8f 100644 --- a/yobble/Views/Tab/ChatsTab.swift +++ b/yobble/Views/Tab/ChatsTab.swift @@ -320,7 +320,7 @@ struct ChatsTab: View { } .background( NavigationLink( - destination: ChatPlaceholderView(chat: chat), + destination: PrivateChatView(chat: chat, currentUserId: currentUserId), tag: chat.chatId, selection: $selectedChatId ) { @@ -965,66 +965,6 @@ struct ChatsTab_Previews: PreviewProvider { } } -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") }