ios_client
This commit is contained in:
246
ios_client 0.8.5/Kecalek/ViewModels/ConversationListVM.swift
Normal file
246
ios_client 0.8.5/Kecalek/ViewModels/ConversationListVM.swift
Normal file
@@ -0,0 +1,246 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user