ios_client
This commit is contained in:
192
ios_client 0.8.5/Kecalek/ViewModels/AuthViewModel.swift
Normal file
192
ios_client 0.8.5/Kecalek/ViewModels/AuthViewModel.swift
Normal file
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class AuthViewModel {
|
||||
var email = ""
|
||||
var password = ""
|
||||
var confirmPassword = ""
|
||||
var username = ""
|
||||
var confirmationCode = ""
|
||||
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var showConfirmation = false
|
||||
var registrationMessage: String?
|
||||
|
||||
var serverHost = Constants.defaultHost
|
||||
var serverPort = String(Constants.defaultPort)
|
||||
|
||||
var hasSavedCredentials = false
|
||||
var isBiometricLoading = false
|
||||
|
||||
enum AuthMode {
|
||||
case login, register, pairing
|
||||
}
|
||||
var mode: AuthMode = .login
|
||||
|
||||
func checkSavedCredentials() {
|
||||
hasSavedCredentials = KeychainService.hasSavedCredentials() && KeychainService.isBiometricAvailable()
|
||||
}
|
||||
|
||||
func login(appState: AppState) async {
|
||||
guard !email.isEmpty, !password.isEmpty else {
|
||||
errorMessage = "Email and password are required"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
// Only connect if not already connected
|
||||
if await !appState.chatClient.isConnected {
|
||||
do {
|
||||
let port = UInt16(serverPort) ?? Constants.defaultPort
|
||||
try await appState.chatClient.connect(host: serverHost, port: port)
|
||||
} catch {
|
||||
isLoading = false
|
||||
errorMessage = "Connection failed: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let (success, message) = await appState.chatClient.login(email: email, password: password)
|
||||
isLoading = false
|
||||
|
||||
if success {
|
||||
appState.email = email
|
||||
appState.isLoggedIn = true
|
||||
appState.connectionStatus = .connected
|
||||
appState.startConnectionMonitor()
|
||||
if let userId = await appState.chatClient.userId {
|
||||
appState.currentUser = User(id: userId, username: await appState.chatClient.username, email: email)
|
||||
}
|
||||
|
||||
// Save credentials for biometric login next time
|
||||
if KeychainService.isBiometricAvailable() {
|
||||
let port = UInt16(serverPort) ?? Constants.defaultPort
|
||||
try? KeychainService.saveCredentials(
|
||||
email: email, password: password,
|
||||
host: serverHost, port: port
|
||||
)
|
||||
}
|
||||
|
||||
// Clear password from memory after successful login
|
||||
password = ""
|
||||
confirmPassword = ""
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
func biometricLogin(appState: AppState) async {
|
||||
isBiometricLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let creds = try KeychainService.loadCredentials()
|
||||
email = creds.email
|
||||
password = creds.password
|
||||
serverHost = creds.host
|
||||
serverPort = String(creds.port)
|
||||
isBiometricLoading = false
|
||||
|
||||
await login(appState: appState)
|
||||
|
||||
// If login failed, reset to defaults so the form isn't stuck on stale values
|
||||
if !appState.isLoggedIn {
|
||||
serverHost = Constants.defaultHost
|
||||
serverPort = String(Constants.defaultPort)
|
||||
password = ""
|
||||
KeychainService.deleteCredentials()
|
||||
hasSavedCredentials = false
|
||||
}
|
||||
} catch KeychainService.KeychainError.biometricFailed {
|
||||
isBiometricLoading = false
|
||||
// User cancelled — just let them type manually
|
||||
} catch {
|
||||
isBiometricLoading = false
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func register(appState: AppState) async {
|
||||
guard !email.isEmpty, !password.isEmpty, !username.isEmpty else {
|
||||
errorMessage = "All fields are required"
|
||||
return
|
||||
}
|
||||
guard password == confirmPassword else {
|
||||
errorMessage = "Passwords don't match"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
#if DEBUG
|
||||
print("DEBUG register: connecting to \(serverHost):\(serverPort)")
|
||||
#endif
|
||||
if await !appState.chatClient.isConnected {
|
||||
do {
|
||||
let port = UInt16(serverPort) ?? Constants.defaultPort
|
||||
try await appState.chatClient.connect(host: serverHost, port: port)
|
||||
#if DEBUG
|
||||
print("DEBUG register: connected successfully")
|
||||
#endif
|
||||
} catch {
|
||||
isLoading = false
|
||||
#if DEBUG
|
||||
print("DEBUG register: connection failed - \(error)")
|
||||
#endif
|
||||
errorMessage = "Connection failed: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("DEBUG register: calling chatClient.register")
|
||||
#endif
|
||||
let (success, message) = await appState.chatClient.register(username: username, password: password, email: email)
|
||||
isLoading = false
|
||||
|
||||
#if DEBUG
|
||||
print("DEBUG AuthViewModel: register returned success=\(success), message=\(message)")
|
||||
#endif
|
||||
|
||||
if success {
|
||||
registrationMessage = message
|
||||
showConfirmation = true
|
||||
#if DEBUG
|
||||
print("DEBUG AuthViewModel: showConfirmation set to true")
|
||||
#endif
|
||||
} else {
|
||||
errorMessage = message
|
||||
#if DEBUG
|
||||
print("DEBUG AuthViewModel: errorMessage set to \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func confirmRegistration(appState: AppState) async {
|
||||
guard !confirmationCode.isEmpty else {
|
||||
errorMessage = "Enter the confirmation code"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
let (success, message) = await appState.chatClient.confirmRegistration(
|
||||
email: email, username: username, code: confirmationCode
|
||||
)
|
||||
isLoading = false
|
||||
|
||||
if success {
|
||||
registrationMessage = message
|
||||
// Auto-login after registration
|
||||
await login(appState: appState)
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
356
ios_client 0.8.5/Kecalek/ViewModels/ChatViewModel.swift
Normal file
356
ios_client 0.8.5/Kecalek/ViewModels/ChatViewModel.swift
Normal file
@@ -0,0 +1,356 @@
|
||||
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<Void, Never>?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
98
ios_client 0.8.5/Kecalek/ViewModels/ProfileViewModel.swift
Normal file
98
ios_client 0.8.5/Kecalek/ViewModels/ProfileViewModel.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class ProfileViewModel {
|
||||
var profile: UserProfile?
|
||||
var avatarData: Data?
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var errorMessage: String?
|
||||
|
||||
// Editable fields
|
||||
var phone = ""
|
||||
var phoneVisible = false
|
||||
var location = ""
|
||||
var locationVisible = false
|
||||
|
||||
func loadProfile(userId: String? = nil, chatClient: ChatClient) async {
|
||||
isLoading = true
|
||||
profile = await chatClient.getProfile(userId: userId)
|
||||
isLoading = false
|
||||
|
||||
if let p = profile {
|
||||
phone = p.phone ?? ""
|
||||
phoneVisible = p.phoneVisible
|
||||
location = p.location ?? ""
|
||||
locationVisible = p.locationVisible
|
||||
}
|
||||
|
||||
// Load avatar
|
||||
let clientUserId = await chatClient.userId
|
||||
let uid = userId ?? clientUserId ?? ""
|
||||
if !uid.isEmpty {
|
||||
avatarData = await chatClient.getAvatar(userId: uid)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func saveProfile(chatClient: ChatClient) async -> Bool {
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
|
||||
let success = await chatClient.updateProfile(
|
||||
phone: phone.isEmpty ? nil : phone,
|
||||
phoneVisible: phoneVisible,
|
||||
location: location.isEmpty ? nil : location,
|
||||
locationVisible: locationVisible
|
||||
)
|
||||
|
||||
isSaving = false
|
||||
|
||||
if !success {
|
||||
errorMessage = "Failed to update profile"
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
func uploadAvatar(imageData: Data, chatClient: ChatClient) async {
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
let (success, msg) = await chatClient.updateAvatar(imageData: imageData)
|
||||
isSaving = false
|
||||
|
||||
if success {
|
||||
// Reload avatar from server (it was resized/compressed)
|
||||
let clientUserId = await chatClient.userId ?? ""
|
||||
avatarData = await chatClient.getAvatar(userId: clientUserId)
|
||||
} else {
|
||||
errorMessage = msg.isEmpty ? "Failed to upload avatar" : msg
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Username Change
|
||||
|
||||
func changeUsername(newUsername: String, chatClient: ChatClient) async -> Bool {
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
let (success, msg) = await chatClient.changeUsername(newUsername: newUsername)
|
||||
isSaving = false
|
||||
if !success {
|
||||
errorMessage = msg
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
// MARK: - Password Change
|
||||
|
||||
func changePassword(oldPassword: String, newPassword: String, chatClient: ChatClient) async -> Bool {
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
let (success, msg) = await chatClient.changePassword(oldPassword: oldPassword, newPassword: newPassword)
|
||||
isSaving = false
|
||||
if !success {
|
||||
errorMessage = msg
|
||||
}
|
||||
return success
|
||||
}
|
||||
}
|
||||
60
ios_client 0.8.5/Kecalek/ViewModels/VerificationVM.swift
Normal file
60
ios_client 0.8.5/Kecalek/ViewModels/VerificationVM.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class VerificationVM {
|
||||
var safetyNumber: String?
|
||||
var myFingerprint: String?
|
||||
var peerFingerprint: String?
|
||||
var verificationStatus: String = "unverified" // "verified", "trusted", "unverified"
|
||||
var qrCodeData: Data?
|
||||
var scanResult: String?
|
||||
var scanSuccess: Bool?
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
func loadVerification(peerUserId: String, chatClient: ChatClient) async {
|
||||
isLoading = true
|
||||
|
||||
// Ensure peer's identity key is fetched (needed for safety number & verification)
|
||||
_ = await chatClient.getPeerIdentityKey(userId: peerUserId)
|
||||
|
||||
// Get safety number
|
||||
safetyNumber = await chatClient.getSafetyNumber(peerUserId: peerUserId)
|
||||
|
||||
// Get fingerprints
|
||||
myFingerprint = await chatClient.getMyFingerprint()
|
||||
peerFingerprint = await chatClient.getPeerFingerprint(peerUserId: peerUserId)
|
||||
|
||||
// Get verification status
|
||||
verificationStatus = await chatClient.getVerificationStatus(userId: peerUserId)
|
||||
|
||||
// Get QR code data for display
|
||||
qrCodeData = await chatClient.getVerificationQRData()
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func verifyContact(peerUserId: String, chatClient: ChatClient) async {
|
||||
guard let peerIK = await chatClient.getPeerIdentityKey(userId: peerUserId) else {
|
||||
errorMessage = "No identity key on record for this user."
|
||||
return
|
||||
}
|
||||
await chatClient.verifyContact(userId: peerUserId, identityKey: peerIK, method: "manual")
|
||||
verificationStatus = "verified"
|
||||
}
|
||||
|
||||
func unverifyContact(peerUserId: String, chatClient: ChatClient) async {
|
||||
await chatClient.unverifyContact(userId: peerUserId)
|
||||
verificationStatus = "trusted"
|
||||
}
|
||||
|
||||
func verifyQRCode(data: Data, chatClient: ChatClient) async {
|
||||
let (success, _, message) = await chatClient.verifyQRCode(qrData: data)
|
||||
scanSuccess = success
|
||||
scanResult = message
|
||||
if success {
|
||||
verificationStatus = "verified"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user