import Foundation import SwiftUI @Observable final class ChatViewModel { var messages: [Message] = [] var isLoading = false var isSending = false var errorMessage: String? var searchQuery = "" var searchResults: [String] = [] // message IDs matching search var currentSearchIndex = 0 private var notificationTask: Task? func loadMessages(convId: String, chatClient: ChatClient) async { let email = await chatClient.email let cacheKey = await chatClient.cacheKey // 1. Load from cache let cachedDicts = MessageCache.load(email: email, convId: convId, cacheKey: cacheKey) let cached = cachedDicts?.compactMap { Message.fromCacheDict($0) } ?? [] if !cached.isEmpty { // Cache hit — show immediately, no spinner messages = cached.sorted { $0.createdAt < $1.createdAt } } else { // No cache — show spinner (first open) isLoading = true } // 2. Determine after_ts from newest cached message let newestCached = messages.last // 3. Fetch from server let serverMessages: [Message] if let newest = newestCached { let afterTs = DateParsing.format(newest.createdAt) #if DEBUG print("DEBUG getMessages after_ts=\(afterTs)") #endif serverMessages = await chatClient.getMessages(convId: convId, limit: 50, afterTs: afterTs) } else { serverMessages = await chatClient.getMessages(convId: convId, limit: 50) } // 4. Merge if newestCached != nil { // Incremental: dedup by ID, append new, sort let existingIds = Set(messages.map(\.id)) let newMessages = serverMessages.filter { !existingIds.contains($0.id) } if !newMessages.isEmpty { messages.append(contentsOf: newMessages) messages.sort { $0.createdAt < $1.createdAt } } } else { // Full fetch: replace messages = serverMessages } // 5. Sync deleted (only for incremental) if let newest = newestCached { let afterTs = DateParsing.format(newest.createdAt) #if DEBUG print("DEBUG get_deleted_since since_ts=\(afterTs)") #endif let deletedIds = await chatClient.getDeletedSince(convId: convId, sinceTs: afterTs) if !deletedIds.isEmpty { messages.removeAll { deletedIds.contains($0.id) } } } // 6. Loading done isLoading = false // 7. Save to cache await saveCache(convId: convId, chatClient: chatClient) // 8. Mark entire conversation as read (server-side bulk mark) // This handles messages not in cache (e.g. failed to decrypt or never fetched) await chatClient.markConversationRead(convId: convId) // Update local readBy for cached messages so cache reflects read state let currentUserId = await chatClient.userId ?? "" var anyUpdated = false for i in messages.indices { if !messages[i].isMine(currentUserId: currentUserId) && !messages[i].readBy.contains(currentUserId) { messages[i].readBy.insert(currentUserId) anyUpdated = true } } if anyUpdated { await saveCache(convId: convId, chatClient: chatClient) } } func loadOlderMessages(convId: String, chatClient: ChatClient) async { let older = await chatClient.getMessages(convId: convId, limit: 50, offset: messages.count) messages.insert(contentsOf: older, at: 0) await saveCache(convId: convId, chatClient: chatClient) } func sendMessage(convId: String, text: String, members: [ConversationMember], chatClient: ChatClient, replyTo: String? = nil) async { guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } isSending = true errorMessage = nil let (success, msg, sentMessage) = await chatClient.sendMessage( convId: convId, text: text, members: members, replyTo: replyTo ) isSending = false if !success { errorMessage = msg } else if let sentMessage = sentMessage { // Append locally — don't reload from server (ratchet keys are one-time) if !messages.contains(where: { $0.id == sentMessage.id }) { messages.append(sentMessage) } await saveCache(convId: convId, chatClient: chatClient) } } func deleteMessage(messageId: String, convId: String, chatClient: ChatClient) async { let success = await chatClient.deleteMessage(messageId: messageId, convId: convId) if success { messages.removeAll { $0.id == messageId } await saveCache(convId: convId, chatClient: chatClient) } } func saveCache(convId: String, chatClient: ChatClient) async { let email = await chatClient.email let cacheKey = await chatClient.cacheKey let dicts = messages.map { $0.toCacheDict() } try? MessageCache.save(email: email, convId: convId, messages: dicts, cacheKey: cacheKey) } func search(query: String) { searchQuery = query if query.isEmpty { searchResults = [] currentSearchIndex = 0 return } let lower = query.lowercased() searchResults = messages.filter { $0.text?.lowercased().contains(lower) == true }.map(\.id) currentSearchIndex = searchResults.isEmpty ? 0 : searchResults.count - 1 } func nextSearchResult() { guard !searchResults.isEmpty else { return } currentSearchIndex = (currentSearchIndex + 1) % searchResults.count } func prevSearchResult() { guard !searchResults.isEmpty else { return } currentSearchIndex = (currentSearchIndex - 1 + searchResults.count) % searchResults.count } func startNotificationListener(convId: String, chatClient: ChatClient) { notificationTask?.cancel() notificationTask = Task { for await notification in await chatClient.makeNotificationStream() { await handleNotification(notification, convId: convId, chatClient: chatClient) } } } @MainActor private func handleNotification(_ notification: ChatNotification, convId: String, chatClient: ChatClient) { switch notification { case .newMessage(let data): if data["conversation_id"] as? String == convId { Task { if let message = await chatClient.decryptNotification(data) { await MainActor.run { // Deduplicate — sent messages are already appended locally if !messages.contains(where: { $0.id == message.id }) { messages.append(message) } } await saveCache(convId: convId, chatClient: chatClient) // Only mark as read if from someone else let myId = await chatClient.userId ?? "" if message.senderId != myId { await chatClient.markRead(convId: convId, messageIds: [message.id]) } await chatClient.flushSelfEncrypt() } } } case .messageDeleted(let data): if let msgId = data["message_id"] as? String { messages.removeAll { $0.id == msgId } Task { await saveCache(convId: convId, chatClient: chatClient) } } case .messagesRead(let data): if let readUserId = data["user_id"] as? String, let msgIds = data["message_ids"] as? [String] { for i in messages.indices { if msgIds.contains(messages[i].id) { messages[i].readBy.insert(readUserId) } } } case .messageReacted(let data): if let msgId = data["message_id"] as? String, let reactUserId = data["user_id"] as? String, let reaction = data["reaction"] as? String, let action = data["action"] as? String, let idx = messages.firstIndex(where: { $0.id == msgId }) { if action == "add" { let newReaction = MessageReaction(userId: reactUserId, reaction: reaction, createdAt: Date()) if !messages[idx].reactions.contains(where: { $0.userId == reactUserId && $0.reaction == reaction }) { messages[idx].reactions.append(newReaction) } } else { messages[idx].reactions.removeAll { $0.userId == reactUserId && $0.reaction == reaction } } Task { await saveCache(convId: convId, chatClient: chatClient) } } case .messagePinned(let data): if let msgId = data["message_id"] as? String, let pinUserId = data["user_id"] as? String, let idx = messages.firstIndex(where: { $0.id == msgId }) { messages[idx].pinnedAt = Date() messages[idx].pinnedBy = pinUserId Task { await saveCache(convId: convId, chatClient: chatClient) } } case .messageUnpinned(let data): if let msgId = data["message_id"] as? String, let idx = messages.firstIndex(where: { $0.id == msgId }) { messages[idx].pinnedAt = nil messages[idx].pinnedBy = nil Task { await saveCache(convId: convId, chatClient: chatClient) } } case .messageDelivered(let data): // Delivery receipt — message was successfully received by recipient if let msgId = data["message_id"] as? String, let idx = messages.firstIndex(where: { $0.id == msgId }) { messages[idx].readBy.insert("__delivered__") Task { await saveCache(convId: convId, chatClient: chatClient) } } default: break } } func reactToMessage(messageId: String, convId: String, reaction: String, currentUserId: String, chatClient: ChatClient) async { guard let idx = messages.firstIndex(where: { $0.id == messageId }) else { return } let existingReaction = messages[idx].reactions.first { $0.userId == currentUserId } let hasSameReaction = existingReaction?.reaction == reaction let savedReactions = messages[idx].reactions // Optimistic update if hasSameReaction { // Tapping same emoji — remove it messages[idx].reactions.removeAll { $0.userId == currentUserId } } else { // Remove any previous reaction from this user, then add new one messages[idx].reactions.removeAll { $0.userId == currentUserId } messages[idx].reactions.append(MessageReaction(userId: currentUserId, reaction: reaction, createdAt: Date())) } // If user had a different reaction, remove it on server first if let old = existingReaction, old.reaction != reaction { let _ = await chatClient.reactMessage(messageId: messageId, conversationId: convId, reaction: old.reaction, action: "remove") } // Add or remove the target reaction on server let action = hasSameReaction ? "remove" : "add" let success = await chatClient.reactMessage(messageId: messageId, conversationId: convId, reaction: reaction, action: action) if !success { // Revert on failure messages[idx].reactions = savedReactions } await saveCache(convId: convId, chatClient: chatClient) } func pinMessage(messageId: String, convId: String, pin: Bool, chatClient: ChatClient) async { guard let idx = messages.firstIndex(where: { $0.id == messageId }) else { return } // Optimistic update if pin { messages[idx].pinnedAt = Date() messages[idx].pinnedBy = await chatClient.userId } else { messages[idx].pinnedAt = nil messages[idx].pinnedBy = nil } let success = await chatClient.pinMessage(messageId: messageId, conversationId: convId, action: pin ? "pin" : "unpin") if !success { // Revert on failure if pin { messages[idx].pinnedAt = nil messages[idx].pinnedBy = nil } } await saveCache(convId: convId, chatClient: chatClient) } // MARK: - Forward Message func forwardMessage(message: Message, targetConvId: String, targetMembers: [ConversationMember], chatClient: ChatClient) async -> Bool { var originalMsg: [String: Any] = [ "text": message.text ?? "", "sender": message.senderUsername, "conversation_id": message.conversationId, "message_id": message.id, ] if let file = message.file { originalMsg["file"] = [ "file_id": file.fileId, "aes_key": file.aesKey, "iv": file.iv, "filename": file.filename, "size": file.size, "mime_type": file.mimeType, ] as [String: Any] } if let image = message.image { var imgDict: [String: Any] = [ "file_id": image.fileId, "aes_key": image.aesKey, "iv": image.iv, "filename": image.filename, "size": image.size, ] if let thumb = image.thumbnail { imgDict["thumbnail"] = thumb } originalMsg["image"] = imgDict } let (success, _, _) = await chatClient.forwardMessage( targetConvId: targetConvId, originalMsg: originalMsg, targetMembers: targetMembers ) return success } func stop() { notificationTask?.cancel() notificationTask = nil } }