diff --git a/yobble/CoreData/ChatCacheWriter.swift b/yobble/CoreData/ChatCacheWriter.swift new file mode 100644 index 0000000..f2b4ebb --- /dev/null +++ b/yobble/CoreData/ChatCacheWriter.swift @@ -0,0 +1,233 @@ +import Foundation +import CoreData + +final class ChatCacheWriter { + static let shared = ChatCacheWriter() + + private let persistenceController: PersistenceController + private let userDefaults: UserDefaults + private let keychainService: KeychainService + + init( + persistenceController: PersistenceController = .shared, + userDefaults: UserDefaults = .standard, + keychainService: KeychainService = .shared + ) { + self.persistenceController = persistenceController + self.userDefaults = userDefaults + self.keychainService = keychainService + } + + func storePrivateChatList(_ data: PrivateChatListData) { + guard !AppConfig.DISABLE_DB else { return } + guard let accountUserId = resolveCurrentAccountUserId() else { return } + + let context = persistenceController.newBackgroundContext() + context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + + context.perform { [weak self] in + guard let self else { return } + do { + try self.sync(chatList: data, accountUserId: accountUserId, context: context) + if context.hasChanges { + try context.save() + } + } catch { + if AppConfig.DEBUG { + print("[ChatCacheWriter] Failed to store private chat list: \(error)") + } + } + } + } + + private func resolveCurrentAccountUserId() -> String? { + guard let login = userDefaults.string(forKey: "currentUser") else { + return nil + } + return keychainService.get(forKey: "userId", service: login) + } + + private func sync(chatList: PrivateChatListData, accountUserId: String, context: NSManagedObjectContext) throws { + var profileCache: [String: CDProfile] = [:] + var chatCache: [String: CDPrivateChat] = [:] + var messageCache: [String: CDMessage] = [:] + + for item in chatList.items { + let chat = try upsertChat(item, accountUserId: accountUserId, context: context, cache: &chatCache) + let previousLastMessage = chat.lastMessage + + if let profileData = item.chatData { + let profile = try upsertProfile(profileData, accountUserId: accountUserId, context: context, cache: &profileCache) + chat.chatData = profile + } else { + chat.chatData = nil + } + + if let lastMessageData = item.lastMessage { + let message = try upsertMessage( + lastMessageData, + accountUserId: accountUserId, + chat: chat, + context: context, + profileCache: &profileCache, + messageCache: &messageCache + ) + if let previous = previousLastMessage, previous.objectID != message.objectID { + previous.chatAsLastMessage = nil + } + chat.lastMessage = message + } else { + previousLastMessage?.chatAsLastMessage = nil + chat.lastMessage = nil + } + } + } + + private func upsertChat( + _ chatItem: PrivateChatListItem, + accountUserId: String, + context: NSManagedObjectContext, + cache: inout [String: CDPrivateChat] + ) throws -> CDPrivateChat { + if let cached = cache[chatItem.chatId] { + apply(chatItem: chatItem, to: cached, accountUserId: accountUserId) + return cached + } + + let chat = try fetchChat(chatId: chatItem.chatId, accountUserId: accountUserId, context: context) ?? CDPrivateChat(context: context) + apply(chatItem: chatItem, to: chat, accountUserId: accountUserId) + cache[chatItem.chatId] = chat + return chat + } + + private func apply(chatItem: PrivateChatListItem, to chat: CDPrivateChat, accountUserId: String) { + chat.accountUserId = accountUserId + chat.chatId = chatItem.chatId + chat.chatType = chatItem.chatType.rawValue + chat.createdAt = chatItem.createdAt + chat.unreadCount = Int32(chatItem.unreadCount) + } + + private func upsertProfile( + _ profileData: ChatProfile, + accountUserId: String, + context: NSManagedObjectContext, + cache: inout [String: CDProfile] + ) throws -> CDProfile { + if let cached = cache[profileData.userId] { + apply(profile: profileData, to: cached, accountUserId: accountUserId) + return cached + } + + let profile = try fetchProfile(userId: profileData.userId, accountUserId: accountUserId, context: context) ?? CDProfile(context: context) + apply(profile: profileData, to: profile, accountUserId: accountUserId) + cache[profileData.userId] = profile + return profile + } + + private func apply(profile profileData: ChatProfile, to profile: CDProfile, accountUserId: String) { + profile.accountUserId = accountUserId + profile.userId = profileData.userId + profile.login = profileData.login + profile.fullName = profileData.fullName + profile.customName = profileData.customName + profile.bio = profileData.bio + if let lastSeen = profileData.lastSeen { + profile.setValue(NSNumber(value: lastSeen), forKey: "lastSeen") + } else { + profile.setValue(nil, forKey: "lastSeen") + } + profile.createdAt = profileData.createdAt + profile.isOfficial = profileData.isOfficial + } + + private func upsertMessage( + _ messageData: MessageItem, + accountUserId: String, + chat: CDPrivateChat, + context: NSManagedObjectContext, + profileCache: inout [String: CDProfile], + messageCache: inout [String: CDMessage] + ) throws -> CDMessage { + if let cached = messageCache[messageData.messageId] { + apply(message: messageData, to: cached, accountUserId: accountUserId, chat: chat, context: context, profileCache: &profileCache) + return cached + } + + let message = try fetchMessage(messageId: messageData.messageId, accountUserId: accountUserId, context: context) ?? CDMessage(context: context) + apply(message: messageData, to: message, accountUserId: accountUserId, chat: chat, context: context, profileCache: &profileCache) + messageCache[messageData.messageId] = message + return message + } + + private func apply( + message messageData: MessageItem, + to message: CDMessage, + accountUserId: String, + chat: CDPrivateChat, + context: NSManagedObjectContext, + profileCache: inout [String: CDProfile] + ) { + message.accountUserId = accountUserId + message.messageId = messageData.messageId + message.chatId = messageData.chatId + message.messageType = messageData.messageType + message.content = messageData.content + message.mediaLink = messageData.mediaLink + message.createdAt = messageData.createdAt + message.updatedAt = messageData.updatedAt + message.senderId = messageData.senderId + message.chat = chat + message.chatAsLastMessage = chat + + if let isViewed = messageData.isViewed { + message.setValue(NSNumber(value: isViewed), forKey: "isViewed") + } else { + message.setValue(nil, forKey: "isViewed") + } + + if let forward = messageData.forwardMetadata { + message.forwardType = forward.forwardType + message.forwardSenderId = forward.forwardSenderId + message.forwardMessageId = forward.forwardMessageId + } else { + message.forwardType = nil + message.forwardSenderId = nil + message.forwardMessageId = nil + } + + if let senderProfileData = messageData.senderData { + if let profile = try? upsertProfile( + senderProfileData, + accountUserId: accountUserId, + context: context, + cache: &profileCache + ) { + message.sender = profile + } + } else { + message.sender = nil + } + } + + private func fetchChat(chatId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDPrivateChat? { + let request = NSFetchRequest(entityName: "CDPrivateChat") + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "accountUserId == %@ AND chatId == %@", accountUserId, chatId) + return try context.fetch(request).first + } + + private func fetchProfile(userId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDProfile? { + let request = NSFetchRequest(entityName: "CDProfile") + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "accountUserId == %@ AND userId == %@", accountUserId, userId) + return try context.fetch(request).first + } + + private func fetchMessage(messageId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDMessage? { + let request = NSFetchRequest(entityName: "CDMessage") + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "accountUserId == %@ AND messageId == %@", accountUserId, messageId) + return try context.fetch(request).first + } +} diff --git a/yobble/Network/ChatService.swift b/yobble/Network/ChatService.swift index 6553d55..8803a20 100644 --- a/yobble/Network/ChatService.swift +++ b/yobble/Network/ChatService.swift @@ -19,12 +19,14 @@ enum ChatServiceError: LocalizedError { final class ChatService { private let client: NetworkClient private let decoder: JSONDecoder + private let cacheWriter: ChatCacheWriter - init(client: NetworkClient = .shared) { + init(client: NetworkClient = .shared, cacheWriter: ChatCacheWriter = .shared) { self.client = client self.decoder = JSONDecoder() self.decoder.keyDecodingStrategy = .convertFromSnakeCase self.decoder.dateDecodingStrategy = .custom(Self.decodeDate) + self.cacheWriter = cacheWriter } func fetchPrivateChats( @@ -42,7 +44,7 @@ final class ChatService { method: .get, query: query, requiresAuth: true - ) { [decoder] result in + ) { [decoder, weak self] result in switch result { case .success(let response): do { @@ -52,7 +54,9 @@ final class ChatService { completion(.failure(ChatServiceError.unexpectedStatus(message))) return } - completion(.success(apiResponse.data)) + let chatData = apiResponse.data + self?.cacheWriter.storePrivateChatList(chatData) + completion(.success(chatData)) } catch { let debugMessage = Self.describeDecodingError(error: error, data: response.data) if AppConfig.DEBUG {