import Foundation import SwiftUI @Observable final class ConversationListVM { var conversations: [Conversation] = [] var invitations: [Invitation] = [] var onlineUsers: Set = [] var unreadCounts: [String: Int] = [:] var favorites: Set = [] var avatarCache: [String: Data] = [:] // convId -> avatar image data var isLoading = false private var notificationTask: Task? private var avatarTask: Task? private var refreshTask: Task? private var localKey: Data? private var email: String = "" private var lastRefreshTime: Date = .distantPast func load(chatClient: ChatClient, email: String) async { isLoading = true self.email = email // Load favorites from disk (encrypted with localKey) localKey = await chatClient.localKey favorites = KeyStorage.loadFavorites(email: email, localKey: localKey) let currentUserId = await chatClient.userId ?? "" // Load cached conversations immediately (show while fetching from server) if let cached = MessageCache.loadConversations(email: email, cacheKey: localKey) { conversations = sortConversations(cached, currentUserId: currentUserId) for conv in conversations where conv.unreadCount > 0 { unreadCounts[conv.id] = conv.unreadCount } } // Load cached avatars from disk let diskAvatars = MessageCache.loadAllAvatars(email: email, cacheKey: localKey) if !diskAvatars.isEmpty { avatarCache = diskAvatars } // Fetch conversations from server let convs = await chatClient.listConversations() if !convs.isEmpty { // Sync unread counts from server (authoritative source) for conv in convs { unreadCounts[conv.id] = conv.unreadCount } conversations = sortConversations(convs, currentUserId: currentUserId) // Save to cache MessageCache.saveConversations(email: email, conversations: convs, cacheKey: localKey) } // Fetch invitations invitations = await chatClient.listInvitations() isLoading = false lastRefreshTime = Date() // Start notification listener startNotificationListener(chatClient: chatClient, email: email) // Read initial online users stored in ChatClient // (online_users notification arrives during login before any subscriber exists) onlineUsers = await chatClient.onlineUserIds // Load avatars in background (non-blocking) avatarTask?.cancel() avatarTask = Task { await loadAvatars(chatClient: chatClient, currentUserId: currentUserId) } } func refresh(chatClient: ChatClient) async { // Debounce: skip if refreshed < 2s ago guard Date().timeIntervalSince(lastRefreshTime) > 2 else { #if DEBUG print("DEBUG ConversationListVM: refresh debounced") #endif return } lastRefreshTime = Date() let currentUserId = await chatClient.userId ?? "" let convs = await chatClient.listConversations() if !convs.isEmpty { // Sync unread counts from server (authoritative source) for conv in convs { unreadCounts[conv.id] = conv.unreadCount } conversations = sortConversations(convs, currentUserId: currentUserId) // Save to cache MessageCache.saveConversations(email: email, conversations: convs, cacheKey: localKey) } invitations = await chatClient.listInvitations() // Refresh avatars in background avatarTask?.cancel() avatarTask = Task { await loadAvatars(chatClient: chatClient, currentUserId: currentUserId) } } func toggleFavorite(convId: String, email: String) { if favorites.contains(convId) { favorites.remove(convId) } else { favorites.insert(convId) } try? KeyStorage.saveFavorites(email: email, favorites: favorites, localKey: localKey) // Re-sort let userId = conversations.first?.createdBy ?? "" conversations = sortConversations(conversations, currentUserId: userId) } func forceRefresh(chatClient: ChatClient) async { lastRefreshTime = .distantPast await refresh(chatClient: chatClient) } func updateAvatar(convId: String, data: Data) { avatarCache[convId] = data // Persist to disk so it survives load() re-reads MessageCache.saveAvatar(email: email, key: convId, data: data, cacheKey: localKey) } func markConversationRead(convId: String) { unreadCounts[convId] = 0 } func incrementUnread(convId: String) { unreadCounts[convId, default: 0] += 1 } private func sortConversations(_ convs: [Conversation], currentUserId: String) -> [Conversation] { var result = convs.map { conv -> Conversation in var c = conv c.isFavorite = favorites.contains(conv.id) c.unreadCount = unreadCounts[conv.id] ?? conv.unreadCount return c } result.sort { a, b in // Favorites first if a.isFavorite != b.isFavorite { return a.isFavorite } // Online DMs next let aOnline = a.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false let bOnline = b.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false if aOnline != bOnline { return aOnline } // Alphabetical return a.displayName(currentUserId: currentUserId).lowercased() < b.displayName(currentUserId: currentUserId).lowercased() } return result } private func startNotificationListener(chatClient: ChatClient, email: String) { notificationTask?.cancel() notificationTask = Task { for await notification in await chatClient.makeNotificationStream() { await handleNotification(notification, chatClient: chatClient, email: email) } } } @MainActor private func handleNotification(_ notification: ChatNotification, chatClient: ChatClient, email: String) { switch notification { case .newMessage(let data): if let convId = data["conversation_id"] as? String { incrementUnread(convId: convId) } case .onlineUsers(let userIds): onlineUsers = Set(userIds) case .userOnline(let userId): onlineUsers.insert(userId) case .userOffline(let userId): onlineUsers.remove(userId) case .conversationCreated, .memberAdded, .memberRemoved, .conversationRenamed, .conversationDeleted: refreshTask?.cancel() refreshTask = Task { await refresh(chatClient: chatClient) } case .groupInvitation: Task { invitations = await chatClient.listInvitations() } case .reconnected: #if DEBUG print("DEBUG ConversationListVM: reconnected — refreshing") #endif refreshTask?.cancel() refreshTask = Task { await refresh(chatClient: chatClient) } case .connectionStateChanged(let connected): if !connected { #if DEBUG print("DEBUG ConversationListVM: disconnected") #endif } default: break } } private func loadAvatars(chatClient: ChatClient, currentUserId: String) async { await withTaskGroup(of: (String, Data?).self) { group in for conv in conversations { let convId = conv.id // Skip if already cached in memory if avatarCache[convId] != nil { continue } if conv.isGroup { // Only fetch if group has an avatar file if conv.avatarFile != nil { group.addTask { let data = await chatClient.getGroupAvatar(convId: convId) return (convId, data) } } } else { // DM: fetch partner's avatar if let partnerId = conv.dmPartnerId(currentUserId: currentUserId) { group.addTask { let data = await chatClient.getAvatar(userId: partnerId) return (convId, data) } } } } let emailCapture = email let keyCapture = localKey for await (convId, data) in group { if let data = data { avatarCache[convId] = data // Save to disk cache MessageCache.saveAvatar(email: emailCapture, key: convId, data: data, cacheKey: keyCapture) } } } } func stop() { notificationTask?.cancel() notificationTask = nil avatarTask?.cancel() avatarTask = nil refreshTask?.cancel() refreshTask = nil } }