ios_client
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user