add history load

This commit is contained in:
cheykrym 2025-10-08 05:55:32 +03:00
parent c9bfab0b14
commit 207187a439
6 changed files with 405 additions and 73 deletions

View File

@ -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`

View File

@ -66,6 +66,51 @@ final class ChatService {
}
}
func fetchPrivateChatHistory(
chatId: String,
beforeMessageId: String?,
limit: Int,
completion: @escaping (Result<PrivateChatHistoryData, Error>) -> 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<PrivateChatHistoryData>.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)

View File

@ -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" : {

View File

@ -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<String> = []
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
}
}

View File

@ -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.

View File

@ -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")
}