initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project

This commit is contained in:
2026-08-12 00:22:01 +00:00
commit 9d1b0f8dd9
52 changed files with 6271 additions and 0 deletions

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

View File

@@ -0,0 +1,60 @@
import Foundation
import Combine
/// View model for the presence experience.
///
/// Exposes the state the UI needs to render the "am I present?" moment, the
/// radius tier picker, and the live feed of encounters (silhouettes).
@MainActor
final class PresenceViewModel: ObservableObject {
@Published var isPresent: Bool = false
@Published var radiusTier: DistanceTier = .rightHere
@Published private(set) var activeEngines: Set<ProximityEngine> = []
@Published private(set) var encounters: [Encounter] = []
@Published private(set) var lastScan: Date?
private let engine: PresenceEngine
private var cancellables = Set<AnyCancellable>()
init(engine: PresenceEngine) {
self.engine = engine
engine.$isPresent
.assign(to: &$isPresent)
engine.$radiusTier
.assign(to: &$radiusTier)
engine.$activeEngines
.assign(to: &$activeEngines)
engine.$recentEncounters
.assign(to: &$encounters)
engine.$lastScan
.assign(to: &$lastScan)
}
// MARK: - User actions
func togglePresence() {
isPresent.toggle()
Task { await engine.setPresent(isPresent) }
}
func setRadius(_ tier: DistanceTier) {
radiusTier = tier
Task { await engine.setRadiusTier(tier) }
}
/// Send a "wave" to a silhouette the first step toward mutual reveal.
func wave(to encounter: Encounter) {
// In production: POST /v1/wave with the encounter token.
Task {
await engine.wave(to: encounter)
}
}
func block(_ encounter: Encounter) {
Task {
await engine.block(encounter)
}
}
}