add chat list

This commit is contained in:
cheykrym 2025-10-06 04:34:07 +03:00
parent 21dd8f01af
commit bc3ec97d67
5 changed files with 739 additions and 9 deletions

View File

@ -0,0 +1,228 @@
import Foundation
struct PrivateChatListData: Decodable {
let items: [PrivateChatListItem]
let hasMore: Bool
}
struct PrivateChatListItem: Decodable, Identifiable {
enum ChatType: String, Decodable {
case `self`
case privateChat = "private"
case unknown
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawValue = try container.decode(String.self)
self = ChatType(rawValue: rawValue) ?? .unknown
}
}
let chatId: String
let chatType: ChatType
let chatData: ChatProfile?
let lastMessage: MessageItem?
let createdAt: Date?
let unreadCount: Int
var id: String { chatId }
}
struct MessageItem: Decodable, Identifiable {
let messageId: String
let messageType: String
let chatId: String
let senderId: String
let senderData: ChatProfile?
let content: String?
let mediaLink: String?
let isViewed: Bool?
let createdAt: Date?
let updatedAt: Date?
let forwardMetadata: ForwardMetadata?
var id: String { messageId }
private enum CodingKeys: String, CodingKey {
case messageId
case messageType
case chatId
case senderId
case senderData
case content
case mediaLink
case isViewed
case createdAt
case updatedAt
case forwardMetadata
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.messageId = try container.decodeFlexibleString(forKey: .messageId)
self.messageType = try container.decodeFlexibleStringOrArray(forKey: .messageType)
self.chatId = try container.decodeFlexibleString(forKey: .chatId)
self.senderId = try container.decodeFlexibleString(forKey: .senderId)
self.senderData = try container.decodeIfPresent(ChatProfile.self, forKey: .senderData)
self.content = try container.decodeIfPresent(String.self, forKey: .content)
self.mediaLink = try container.decodeIfPresent(String.self, forKey: .mediaLink)
self.isViewed = try container.decodeIfPresent(Bool.self, forKey: .isViewed)
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
self.updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt)
self.forwardMetadata = try container.decodeIfPresent(ForwardMetadata.self, forKey: .forwardMetadata)
}
}
struct ForwardMetadata: Decodable {
let forwardType: String?
let forwardSenderId: String?
let forwardMessageId: String?
let forwardChatData: ChatProfile?
private enum CodingKeys: String, CodingKey {
case forwardType
case forwardSenderId
case forwardMessageId
case forwardChatData
}
}
struct ChatProfile: Decodable {
let userId: String
let login: String?
let fullName: String?
let customName: String?
let bio: String?
let lastSeen: Int?
let createdAt: Date?
let stories: [JSONValue]
let permissions: ChatPermissions?
let profilePermissions: ChatProfilePermissions?
let relationship: RelationshipStatus?
private enum CodingKeys: String, CodingKey {
case userId
case login
case fullName
case customName
case bio
case lastSeen
case createdAt
case stories
case permissions
case profilePermissions
case relationship
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.userId = try container.decodeFlexibleString(forKey: .userId)
self.login = try container.decodeIfPresent(String.self, forKey: .login)
self.fullName = try container.decodeIfPresent(String.self, forKey: .fullName)
self.customName = try container.decodeIfPresent(String.self, forKey: .customName)
self.bio = try container.decodeIfPresent(String.self, forKey: .bio)
self.lastSeen = try container.decodeIfPresent(Int.self, forKey: .lastSeen)
self.createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt)
self.stories = try container.decodeIfPresent([JSONValue].self, forKey: .stories) ?? []
self.permissions = try container.decodeIfPresent(ChatPermissions.self, forKey: .permissions)
self.profilePermissions = try container.decodeIfPresent(ChatProfilePermissions.self, forKey: .profilePermissions)
self.relationship = try container.decodeIfPresent(RelationshipStatus.self, forKey: .relationship)
}
}
struct ChatPermissions: Decodable {
let youCanSendMessage: Bool
let youCanPublicInvitePermission: Bool
let youCanGroupInvitePermission: Bool
let youCanCallPermission: Bool
}
struct ChatProfilePermissions: Decodable {
let isSearchable: Bool?
let allowMessageForwarding: Bool
let allowMessagesFromNonContacts: Bool
let allowServerChats: Bool
let forceAutoDeleteMessagesInPrivate: Bool
let maxMessageAutoDeleteSeconds: Int?
}
struct RelationshipStatus: Decodable {
let isCurrentUserInContactsOfTarget: Bool
let isTargetUserBlockedByCurrentUser: Bool
let isCurrentUserInBlacklistOfTarget: Bool
}
enum JSONValue: Decodable {
case string(String)
case int(Int)
case double(Double)
case bool(Bool)
case array([JSONValue])
case object([String: JSONValue])
case null
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
return
}
if let value = try? container.decode(Bool.self) {
self = .bool(value)
return
}
if let value = try? container.decode(Int.self) {
self = .int(value)
return
}
if let value = try? container.decode(Double.self) {
self = .double(value)
return
}
if let value = try? container.decode(String.self) {
self = .string(value)
return
}
if let value = try? container.decode([JSONValue].self) {
self = .array(value)
return
}
if let value = try? container.decode([String: JSONValue].self) {
self = .object(value)
return
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Не удалось декодировать значение JSONValue")
}
}
private extension KeyedDecodingContainer {
func decodeFlexibleString(forKey key: Key) throws -> String {
if let string = try? decode(String.self, forKey: key) {
return string
}
if let int = try? decode(Int.self, forKey: key) {
return String(int)
}
if let double = try? decode(Double.self, forKey: key) {
return String(double)
}
throw DecodingError.typeMismatch(String.self, DecodingError.Context(codingPath: codingPath + [key], debugDescription: "Expected to decode String or number for key \(key.stringValue)"))
}
func decodeFlexibleStringOrArray(forKey key: Key) throws -> String {
if let string = try? decode(String.self, forKey: key) {
return string
}
if let stringArray = try? decode([String].self, forKey: key), let first = stringArray.first {
return first
}
if let intArray = try? decode([Int].self, forKey: key), let first = intArray.first {
return String(first)
}
if let doubleArray = try? decode([Double].self, forKey: key), let first = doubleArray.first {
return String(first)
}
throw DecodingError.typeMismatch(String.self, DecodingError.Context(codingPath: codingPath + [key], debugDescription: "Expected to decode String or array for key \(key.stringValue)"))
}
}

View File

@ -0,0 +1,145 @@
import Foundation
enum ChatServiceError: LocalizedError {
case unexpectedStatus(String)
case decoding(debugDescription: String)
var errorDescription: String? {
switch self {
case .unexpectedStatus(let message):
return message
case .decoding(let debugDescription):
return AppConfig.DEBUG
? debugDescription
: NSLocalizedString("Не удалось обработать данные чатов.", comment: "")
}
}
}
final class ChatService {
private let client: NetworkClient
private let decoder: JSONDecoder
init(client: NetworkClient = .shared) {
self.client = client
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
}
func fetchPrivateChats(
offset: Int,
limit: Int,
completion: @escaping (Result<PrivateChatListData, Error>) -> Void
) {
let query: [String: String?] = [
"offset": String(offset),
"limit": String(limit)
]
client.request(
path: "/v1/chat/private/list",
method: .get,
query: query,
requiresAuth: true
) { [decoder] result in
switch result {
case .success(let response):
do {
let apiResponse = try decoder.decode(APIResponse<PrivateChatListData>.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 chats 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)
if let date = iso8601WithFractionalSeconds.date(from: string) {
return date
}
if let date = iso8601Simple.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Невозможно декодировать дату: \(string)"
)
}
private static func describeDecodingError(error: Error, data: Data) -> String {
var parts: [String] = []
if let decodingError = error as? DecodingError {
parts.append(decodingDescription(from: decodingError))
} else {
parts.append(error.localizedDescription)
}
if let payload = truncatedPayload(from: data) {
parts.append("payload=\(payload)")
}
return parts.joined(separator: "\n")
}
private static func decodingDescription(from error: DecodingError) -> String {
switch error {
case .typeMismatch(let type, let context):
return "Type mismatch for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .valueNotFound(let type, let context):
return "Value not found for \(type) at \(codingPath(from: context)): \(context.debugDescription)"
case .keyNotFound(let key, let context):
return "Missing key '\(key.stringValue)' at \(codingPath(from: context)): \(context.debugDescription)"
case .dataCorrupted(let context):
return "Corrupted data at \(codingPath(from: context)): \(context.debugDescription)"
@unknown default:
return error.localizedDescription
}
}
private static func codingPath(from context: DecodingError.Context) -> String {
let path = context.codingPath.map { $0.stringValue }.filter { !$0.isEmpty }
return path.isEmpty ? "root" : path.joined(separator: ".")
}
private static func truncatedPayload(from data: Data, limit: Int = 512) -> String? {
guard let string = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!string.isEmpty else {
return nil
}
if string.count <= limit {
return string
}
let index = string.index(string.startIndex, offsetBy: limit)
return String(string[string.startIndex..<index]) + ""
}
private static let iso8601WithFractionalSeconds: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private static let iso8601Simple: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
}

View File

@ -10,6 +10,9 @@
}
}
}
},
"%lld" : {
},
"🌍" : {
"localizations" : {
@ -82,6 +85,9 @@
},
"Ваше предложение" : {
},
"Вложение" : {
},
"Войти" : {
"localizations" : {
@ -142,6 +148,9 @@
},
"Заглушка: Хранилище данных" : {
},
"Загружаем чаты…" : {
},
"Загрузка..." : {
"localizations" : {
@ -185,15 +194,15 @@
}
}
}
},
"Здесь будут чаты" : {
},
"Здесь не будут чаты" : {
},
"Идеи" : {
},
"Избранные сообщения" : {
},
"Инвайт-код (необязательно)" : {
"comment" : "Инвайт-код",
@ -334,6 +343,15 @@
}
}
}
},
"Не удалось загрузить список чатов." : {
},
"Не удалось загрузить чаты." : {
},
"Не удалось обработать данные чатов." : {
},
"Не удалось обработать ответ сервера." : {
@ -361,15 +379,27 @@
},
"Неизвестная ошибка." : {
},
"Неизвестная ошибка. Попробуйте позже." : {
},
"Неизвестный пользователь" : {
},
"Некорректный ответ от сервера." : {
},
"Нет аккаунта? Регистрация" : {
"comment" : "Регистрация"
},
"Нет сообщений" : {
},
"О приложении" : {
},
"Обновить" : {
},
"Обратная связь" : {
@ -394,12 +424,18 @@
},
"Ошибка регистрация" : {
"comment" : "Ошибка"
},
"Ошибка сервера (%@)." : {
},
"Ошибка сервера: %@" : {
},
"Ошибка сети: %@" : {
},
"Ошибка соединения с сервером." : {
},
"Пароли не совпадают" : {
"comment" : "Пароли не совпадают"
@ -412,12 +448,18 @@
},
"Перейдите в раздел \"Настройки > Сменить пароль\" и следуйте инструкциям." : {
"comment" : "FAQ answer: reset password"
},
"Повторить" : {
},
"Поддержка" : {
},
"Подтверждение пароля" : {
"comment" : "Подтверждение пароля"
},
"Пока что у вас нет чатов" : {
},
"Помощь" : {
"comment" : "Help Center",
@ -474,6 +516,9 @@
},
"Сервер не отвечает. Попробуйте позже." : {
},
"Сессия истекла. Войдите снова." : {
},
"Скан" : {
"comment" : "Scan",
@ -491,6 +536,9 @@
},
"Сменить пароль" : {
},
"Сообщение" : {
},
"Спасибо!" : {

View File

@ -0,0 +1,91 @@
import Foundation
final class PrivateChatsViewModel: ObservableObject {
@Published private(set) var chats: [PrivateChatListItem] = []
@Published private(set) var isInitialLoading: Bool = false
@Published private(set) var isLoadingMore: Bool = false
@Published var errorMessage: String?
private let chatService: ChatService
private let pageSize: Int
private var offset: Int = 0
private var hasMore: Bool = true
init(chatService: ChatService = ChatService(), pageSize: Int = 20) {
self.chatService = chatService
self.pageSize = pageSize
}
func loadInitialChats(force: Bool = false) {
guard !isInitialLoading else { return }
if !force && !chats.isEmpty { return }
isInitialLoading = true
errorMessage = nil
let previousOffset = offset
let previousHasMore = hasMore
offset = 0
hasMore = true
chatService.fetchPrivateChats(offset: 0, limit: pageSize) { [weak self] result in
guard let self else { return }
self.isInitialLoading = false
switch result {
case .success(let data):
self.chats = data.items
self.offset = data.items.count
self.hasMore = data.hasMore
case .failure(let error):
self.errorMessage = self.message(for: error)
self.offset = previousOffset
self.hasMore = previousHasMore
}
}
}
func refresh() {
loadInitialChats(force: true)
}
func loadMoreIfNeeded(currentItem item: PrivateChatListItem) {
guard hasMore, !isLoadingMore, item.id == chats.last?.id else { return }
isLoadingMore = true
chatService.fetchPrivateChats(offset: offset, limit: pageSize) { [weak self] result in
guard let self else { return }
self.isLoadingMore = false
switch result {
case .success(let data):
self.chats.append(contentsOf: data.items)
self.offset = self.chats.count
self.hasMore = data.hasMore
case .failure(let error):
self.errorMessage = self.message(for: error)
}
}
}
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: "")
}
}

View File

@ -8,13 +8,231 @@
import SwiftUI
struct ChatsTab: View {
var body: some View {
VStack {
Text("Здесь будут чаты")
.font(.title)
.foregroundColor(.gray)
@StateObject private var viewModel = PrivateChatsViewModel()
Spacer()
var body: some View {
content
.background(Color(UIColor.systemBackground))
.onAppear {
viewModel.loadInitialChats()
}
}
@ViewBuilder
private var content: some View {
if viewModel.isInitialLoading && viewModel.chats.isEmpty {
loadingState
} else if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
errorState(message: message)
} else if viewModel.chats.isEmpty {
emptyState
} else {
chatList
}
}
private var chatList: some View {
List {
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))
}
ForEach(viewModel.chats) { chat in
ChatRowView(chat: chat)
.contentShape(Rectangle())
.onAppear {
viewModel.loadMoreIfNeeded(currentItem: chat)
}
}
if viewModel.isLoadingMore {
loadingMoreRow
}
}
.listStyle(.plain)
}
private var loadingState: some View {
VStack(spacing: 12) {
ProgressView()
Text(NSLocalizedString("Загружаем чаты…", comment: ""))
.font(.subheadline)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func errorState(message: String) -> some View {
VStack(spacing: 12) {
Image(systemName: "exclamationmark.bubble")
.font(.system(size: 48))
.foregroundColor(.orange)
Text(message)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.primary)
Button(action: { viewModel.loadInitialChats(force: true) }) {
Text(NSLocalizedString("Повторить", comment: ""))
.font(.headline)
}
.buttonStyle(.borderedProminent)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "bubble.left")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
.font(.body)
.foregroundColor(.secondary)
Button(action: { viewModel.refresh() }) {
Text(NSLocalizedString("Обновить", comment: ""))
}
.buttonStyle(.bordered)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var loadingMoreRow: some View {
HStack {
Spacer()
ProgressView()
.padding(.vertical, 12)
Spacer()
}
.listRowSeparator(.hidden)
}
}
private struct ChatRowView: View {
let chat: PrivateChatListItem
private var title: String {
switch chat.chatType {
case .self:
return NSLocalizedString("Избранные сообщения", comment: "")
case .privateChat, .unknown:
if let custom = chat.chatData?.customName, !custom.isEmpty {
return custom
}
if let full = chat.chatData?.fullName, !full.isEmpty {
return full
}
if let login = chat.chatData?.login, !login.isEmpty {
return "@\(login)"
}
return NSLocalizedString("Неизвестный пользователь", comment: "")
}
}
private var subtitle: String {
guard let message = chat.lastMessage else {
return NSLocalizedString("Нет сообщений", comment: "")
}
if let content = message.content, !content.isEmpty {
return content
}
if message.mediaLink != nil {
return NSLocalizedString("Вложение", comment: "")
}
return NSLocalizedString("Сообщение", comment: "")
}
private var timestamp: String? {
let date = chat.lastMessage?.createdAt ?? chat.createdAt
guard let date else { return nil }
return ChatRowView.timeFormatter.string(from: date)
}
private var initial: String {
return String(title.prefix(1)).uppercased()
}
private var subtitleColor: Color {
chat.unreadCount > 0 ? .primary : .secondary
}
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 44, height: 44)
.overlay(
Text(initial)
.font(.headline)
.foregroundColor(Color.accentColor)
)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.fontWeight(chat.unreadCount > 0 ? .semibold : .regular)
.foregroundColor(.primary)
.lineLimit(1)
Text(subtitle)
.font(.subheadline)
.foregroundColor(subtitleColor)
.lineLimit(2)
}
Spacer()
VStack(alignment: .trailing, spacing: 6) {
if let timestamp {
Text(timestamp)
.font(.caption)
.foregroundColor(.secondary)
}
if chat.unreadCount > 0 {
Text("\(chat.unreadCount)")
.font(.caption2.bold())
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule().fill(Color.accentColor)
)
}
}
}
.padding(.vertical, 8)
}
private static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .short
return formatter
}()
}
struct ChatsTab_Previews: PreviewProvider {
static var previews: some View {
ChatsTab()
.environmentObject(ThemeManager())
}
}