Files
Kecalek_python/ios_client 0.8.5/Kecalek/ViewModels/ConversationListVM.swift
2026-03-14 12:43:56 +01:00

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
}
}