add chat list
This commit is contained in:
		
							parent
							
								
									21dd8f01af
								
							
						
					
					
						commit
						bc3ec97d67
					
				
							
								
								
									
										228
									
								
								yobble/Network/ChatModels.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										228
									
								
								yobble/Network/ChatModels.swift
									
									
									
									
									
										Normal 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)"))
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										145
									
								
								yobble/Network/ChatService.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										145
									
								
								yobble/Network/ChatService.swift
									
									
									
									
									
										Normal 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
 | 
			
		||||
    }()
 | 
			
		||||
}
 | 
			
		||||
@ -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 @@
 | 
			
		||||
    },
 | 
			
		||||
    "Сменить пароль" : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Сообщение" : {
 | 
			
		||||
 | 
			
		||||
    },
 | 
			
		||||
    "Спасибо!" : {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										91
									
								
								yobble/ViewModels/PrivateChatsViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								yobble/ViewModels/PrivateChatsViewModel.swift
									
									
									
									
									
										Normal 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: "")
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -8,13 +8,231 @@
 | 
			
		||||
import SwiftUI
 | 
			
		||||
 | 
			
		||||
struct ChatsTab: View {
 | 
			
		||||
    @StateObject private var viewModel = PrivateChatsViewModel()
 | 
			
		||||
 | 
			
		||||
    var body: some View {
 | 
			
		||||
        VStack {
 | 
			
		||||
            Text("Здесь будут чаты")
 | 
			
		||||
                .font(.title)
 | 
			
		||||
                .foregroundColor(.gray)
 | 
			
		||||
            
 | 
			
		||||
            Spacer()
 | 
			
		||||
        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())
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user