ios_client

This commit is contained in:
Filip
2026-03-14 12:43:56 +01:00
parent 5fd80e6dd6
commit 214da18779
74 changed files with 13136 additions and 284 deletions

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

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

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

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

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