initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project
This commit is contained in:
109
Proximity/ViewModels/ChatViewModel.swift
Normal file
109
Proximity/ViewModels/ChatViewModel.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
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<AnyCancellable>()
|
||||
|
||||
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] ?? "…"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user