247 lines
9.2 KiB
Swift
247 lines
9.2 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
@Observable
|
|
final class ConversationListVM {
|
|
var conversations: [Conversation] = []
|
|
var invitations: [Invitation] = []
|
|
var onlineUsers: Set<String> = []
|
|
var unreadCounts: [String: Int] = [:]
|
|
var favorites: Set<String> = []
|
|
var avatarCache: [String: Data] = [:] // convId -> avatar image data
|
|
var isLoading = false
|
|
|
|
private var notificationTask: Task<Void, Never>?
|
|
private var avatarTask: Task<Void, Never>?
|
|
private var refreshTask: Task<Void, Never>?
|
|
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
|
|
}
|
|
}
|