import Foundation import Combine import CryptoKit /// View model for an E2E-encrypted conversation with a revealed connection. /// /// Messages are encrypted with the session key established at reveal time /// (see `RevealFlow`). The server only ever sees ciphertext — it cannot read /// the conversation. This is the product's promise kept in the most intimate /// place: the words between two people who chose to meet. @MainActor final class ChatViewModel: ObservableObject { @Published private(set) var messages: [ChatMessage] = [] @Published var draft: String = "" @Published private(set) var isSending = false private let apiClient: APIClient private let cryptoManager: CryptoManager private let graphStore: GraphStore private let realtime: RealtimeClient /// The active connection we're chatting with. private var connection: Connection? /// The E2E session key for this conversation. private var sessionKey: SymmetricKey? /// Decrypted plaintext cache keyed by message id. @Published private(set) var plaintextByID: [UUID: String] = [:] private var cancellables = Set() init(apiClient: APIClient, cryptoManager: CryptoManager, graphStore: GraphStore, realtime: RealtimeClient) { self.apiClient = apiClient self.cryptoManager = cryptoManager self.graphStore = graphStore self.realtime = realtime // Live wire: decrypt and append incoming messages in real time. realtime.incomingMessage .sink { [weak self] message in Task { await self?.receive(message) } } .store(in: &cancellables) } // MARK: - Setup /// Begin a conversation with a revealed connection. func start(with connection: Connection, sessionKey: SymmetricKey?) { self.connection = connection self.sessionKey = sessionKey self.messages = graphStore.messages.filter { $0.connectionID == connection.id } } // MARK: - Sending func send() async { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, let connection, let sessionKey else { return } isSending = true defer { isSending = false } do { let plaintext = Data(text.utf8) let ciphertext = try cryptoManager.encrypt(plaintext, using: sessionKey) let message = ChatMessage( connectionID: connection.id, senderID: "me", ciphertext: ciphertext ) messages.append(message) plaintextByID[message.id] = text graphStore.addMessage(message) draft = "" try await apiClient.sendMessage(message) } catch { // Keep the message locally; surface send failure to the UI. } } // MARK: - Receiving /// Decrypt and append an incoming message (called live from the socket). func receive(_ message: ChatMessage) async { guard let sessionKey, message.connectionID == connection?.id else { return } // Deduplicate against messages we already have. guard !messages.contains(where: { $0.id == message.id }) else { return } // Decrypt and cache the plaintext for display. if let plaintext = try? cryptoManager.decrypt(message.ciphertext, using: sessionKey), let text = String(data: plaintext, encoding: .utf8) { plaintextByID[message.id] = text messages.append(message) graphStore.addMessage(message) } } /// The decrypted text for a message, for display. func text(for message: ChatMessage) -> String { plaintextByID[message.id] ?? "…" } }