add history load
This commit is contained in:
parent
c9bfab0b14
commit
207187a439
@ -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`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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" : {
|
||||
|
||||
128
yobble/ViewModels/PrivateChatViewModel.swift
Normal file
128
yobble/ViewModels/PrivateChatViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
211
yobble/Views/Chat/PrivateChatView.swift
Normal file
211
yobble/Views/Chat/PrivateChatView.swift
Normal 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.
|
||||
@ -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")
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user