From 4b287f749073ee4a2df30a2112efcad88e80c7a4 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Jun 2026 20:55:25 +0200 Subject: [PATCH 1/9] Stop paying for filtered log messages; sample per-event hot-path logs SecureLogger's public wrappers evaluated their message autoclosure before the level check inside log(), so every filtered debug message across the codebase still paid for string interpolation - hundreds of call sites on the per-packet/per-event hot paths. The wrappers now guard the level first, and debug() compiles out of release builds entirely. Regression tests verify filtered messages are never constructed. The two heaviest per-event debug logs (NostrRelayManager inbound events, ChatNostrCoordinator geo events) are now sampled every 100th with a running count, so debug-enabled dev builds stop emitting hundreds of lines per minute in busy geohashes. Co-Authored-By: Claude Fable 5 --- bitchat/Nostr/NostrRelayManager.swift | 7 ++- bitchat/Services/TransportConfig.swift | 2 + bitchat/ViewModels/ChatNostrCoordinator.swift | 8 ++- .../BitLogger/Sources/SecureLogger.swift | 19 +++++-- .../Tests/LogLevelFilteringTests.swift | 54 +++++++++++++++++++ 5 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 localPackages/BitLogger/Tests/LogLevelFilteringTests.swift diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 2366c68c..2f261548 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -152,6 +152,7 @@ final class NostrRelayManager: ObservableObject { private var recentInboundEventKeyOrder: [InboundEventKey] = [] private var duplicateInboundEventDropCount = 0 private var duplicateInboundEventDropCountBySubscription: [String: Int] = [:] + private var inboundEventLogCount = 0 // Coalesce duplicate subscribe requests for the same id within a short window. private let subscribeCoalesceInterval: TimeInterval = 1.0 private var subscribeCoalesce: [String: Date] = [:] @@ -834,7 +835,11 @@ final class NostrRelayManager: ObservableObject { return } if event.kind != 1059 { - SecureLogger.debug("๐Ÿ“ฅ Event kind=\(event.kind) id=\(event.id.prefix(16))โ€ฆ relay=\(relayUrl)", category: .session) + // Per-event logging floods dev builds in busy geohashes; sample it. + inboundEventLogCount += 1 + if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { + SecureLogger.debug("๐Ÿ“ฅ Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))โ€ฆ relay=\(relayUrl)", category: .session) + } } if let handler = self.messageHandlers[subId] { handler(event) diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 846689d1..6ecf5d1d 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -47,6 +47,8 @@ enum TransportConfig { static let nostrInboundEventDedupCap: Int = 4096 static let nostrInboundEventDedupTrimTarget: Int = 3072 static let nostrDuplicateEventLogInterval: Int = 50 + // Sample interval for per-event debug logs on the inbound hot path. + static let nostrInboundEventLogInterval: Int = 100 // UI thresholds static let uiLateInsertThreshold: TimeInterval = 15.0 diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index 54ef82cf..2c27223e 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -191,6 +191,7 @@ final class ChatNostrCoordinator { private weak var context: (any ChatNostrContext)? private var recentGeoSamplingEventIDs = Set() private var recentGeoSamplingEventIDOrder: [String] = [] + private var geoEventLogCount = 0 init(context: any ChatNostrContext) { self.context = context @@ -450,8 +451,11 @@ final class ChatNostrCoordinator { if context.hasProcessedNostrEvent(event.id) { return } context.recordProcessedNostrEvent(event.id) - let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") - SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))โ€ฆ tags=\(tagSummary)", category: .session) + // Sampled: fires for every geo event and floods dev logs in busy geohashes. + geoEventLogCount += 1 + if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { + SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))โ€ฆ tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session) + } if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { return diff --git a/localPackages/BitLogger/Sources/SecureLogger.swift b/localPackages/BitLogger/Sources/SecureLogger.swift index b6645ea1..2599dfdf 100644 --- a/localPackages/BitLogger/Sources/SecureLogger.swift +++ b/localPackages/BitLogger/Sources/SecureLogger.swift @@ -101,7 +101,8 @@ public final class SecureLogger { // MARK: - Global Threshold /// Minimum level that will be logged. Defaults to .info. Override via env BITCHAT_LOG_LEVEL. - private static let minimumLevel: LogLevel = { + /// Internal-settable so tests can verify level filtering; app code should not mutate it. + internal static var minimumLevel: LogLevel = { let env = ProcessInfo.processInfo.environment["BITCHAT_LOG_LEVEL"]?.lowercased() switch env { case "debug": return .debug @@ -121,23 +122,33 @@ public final class SecureLogger { public extension SecureLogger { + // Each wrapper checks the level BEFORE evaluating the autoclosure so + // filtered messages never pay for string interpolation โ€” this matters on + // hot paths that log per packet/event. Debug compiles out of release + // builds entirely (the core log() drops .debug there anyway). static func debug(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { + #if DEBUG + guard shouldLog(.debug) else { return } log(message(), category: category, level: .debug, file: file, line: line, function: function) + #endif } - + static func info(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { + guard shouldLog(.info) else { return } log(message(), category: category, level: .info, file: file, line: line, function: function) } - + static func warning(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { + guard shouldLog(.warning) else { return } log(message(), category: category, level: .warning, file: file, line: line, function: function) } - + static func error(_ message: @autoclosure () -> String, category: OSLog = .noise, file: String = #file, line: Int = #line, function: String = #function) { + guard shouldLog(.error) else { return } log(message(), category: category, level: .error, file: file, line: line, function: function) } diff --git a/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift b/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift new file mode 100644 index 00000000..db04759f --- /dev/null +++ b/localPackages/BitLogger/Tests/LogLevelFilteringTests.swift @@ -0,0 +1,54 @@ +import XCTest +@testable import BitLogger + +/// The public logging wrappers must not evaluate their message autoclosure +/// when the level is filtered out โ€” hot paths log per packet/event, and +/// building a discarded interpolated string on each call is real overhead. +final class LogLevelFilteringTests: XCTestCase { + private final class EvaluationCounter { + var count = 0 + func message() -> String { + count += 1 + return "expensive interpolation" + } + } + + private var originalLevel: SecureLogger.LogLevel! + + override func setUp() { + super.setUp() + originalLevel = SecureLogger.minimumLevel + } + + override func tearDown() { + SecureLogger.minimumLevel = originalLevel + super.tearDown() + } + + func testFilteredDebugMessageIsNeverEvaluated() { + SecureLogger.minimumLevel = .info + let counter = EvaluationCounter() + + SecureLogger.debug(counter.message()) + + XCTAssertEqual(counter.count, 0, "Filtered debug message should not be constructed") + } + + func testFilteredInfoMessageIsNeverEvaluated() { + SecureLogger.minimumLevel = .error + let counter = EvaluationCounter() + + SecureLogger.info(counter.message()) + + XCTAssertEqual(counter.count, 0, "Filtered info message should not be constructed") + } + + func testEnabledLevelStillEvaluatesMessage() { + SecureLogger.minimumLevel = .debug + let counter = EvaluationCounter() + + SecureLogger.warning(counter.message()) + + XCTAssertEqual(counter.count, 1, "Enabled levels must still log") + } +} From 80bed1f395bbca9ff4490ed26f9e0a81be997b51 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Jun 2026 21:11:06 +0200 Subject: [PATCH 2/9] Add performance baselines; check dedup before verifying Nostr signatures New PerformanceBaselineTests measure the hot paths (Nostr inbound, BLE packet pipeline, GCS filters, delivery index, message formatting) with deterministic fixtures and logged PERF metrics - baselines, not assertions, so they cannot flake CI. The suite immediately exposed that every inbound handler ran Schnorr verification before the dedup lookup, so duplicate events - which dominate real multi-relay traffic - each paid ~0.5ms of main-actor crypto for nothing. All five handlers now do cheap rejects (kind, dedup lookup) first and only record an event as processed AFTER its signature verifies, so a forged-signature copy can never poison the dedup set and suppress the genuine event. Gift-wrap verification also moves entirely off the main actor with an atomic main-actor check-and-record. Measured: duplicate-event handling 2.2k -> 1.39M events/sec (~640x); fresh events unchanged (crypto-bound). Co-Authored-By: Claude Fable 5 --- bitchat/ViewModels/ChatNostrCoordinator.swift | 32 +- .../ChatViewModelExtensionsTests.swift | 14 +- .../PerformanceBaselineTests.swift | 505 ++++++++++++++++++ 3 files changed, 543 insertions(+), 8 deletions(-) create mode 100644 bitchatTests/Performance/PerformanceBaselineTests.swift diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index 2c27223e..cf9b24b9 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -246,13 +246,17 @@ final class ChatNostrCoordinator { @MainActor func subscribeNostrEvent(_ event: NostrEvent) { guard let context else { return } - guard event.isValidSignature() else { return } + // Cheap rejects (kind, dedup lookup) before Schnorr verification โ€” + // duplicates dominate real traffic and must not pay for crypto. + // Only verified events are recorded, so a forged-signature copy can + // never poison the dedup set and suppress the genuine event. guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), !context.hasProcessedNostrEvent(event.id) else { return } + guard event.isValidSignature() else { return } context.recordProcessedNostrEvent(event.id) @@ -327,8 +331,9 @@ final class ChatNostrCoordinator { @MainActor func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { guard let context else { return } - guard giftWrap.isValidSignature() else { return } + // Dedup lookup before Schnorr verification; record only after it passes. guard !context.hasProcessedNostrEvent(giftWrap.id) else { return } + guard giftWrap.isValidSignature() else { return } context.recordProcessedNostrEvent(giftWrap.id) guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( @@ -441,14 +446,15 @@ final class ChatNostrCoordinator { @MainActor func handleNostrEvent(_ event: NostrEvent) { guard let context else { return } - guard event.isValidSignature() else { return } + // Cheap rejects (kind, dedup lookup) before Schnorr verification โ€” + // duplicates dominate real traffic and must not pay for crypto. guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } - if context.hasProcessedNostrEvent(event.id) { return } + guard event.isValidSignature() else { return } context.recordProcessedNostrEvent(event.id) // Sampled: fires for every geo event and floods dev logs in busy geohashes. @@ -559,10 +565,11 @@ final class ChatNostrCoordinator { @MainActor func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { guard let context else { return } - guard giftWrap.isValidSignature() else { return } + // Dedup lookup before Schnorr verification; record only after it passes. if context.hasProcessedNostrEvent(giftWrap.id) { return } + guard giftWrap.isValidSignature() else { return } context.recordProcessedNostrEvent(giftWrap.id) guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( @@ -822,9 +829,12 @@ final class ChatNostrCoordinator { @MainActor func handleNostrMessage(_ giftWrap: NostrEvent) { guard let context else { return } - guard giftWrap.isValidSignature() else { return } + // Cheap dedup pre-check only; Schnorr verification runs off-main in + // processNostrMessage, which then does the authoritative + // check-and-record. Recording stays after verification so a + // forged-signature copy can never poison the dedup set and suppress + // the genuine event. if context.hasProcessedNostrEvent(giftWrap.id) { return } - context.recordProcessedNostrEvent(giftWrap.id) Task.detached(priority: .userInitiated) { [weak self] in await self?.processNostrMessage(giftWrap) @@ -834,6 +844,14 @@ final class ChatNostrCoordinator { func processNostrMessage(_ giftWrap: NostrEvent) async { guard giftWrap.isValidSignature() else { return } guard let context else { return } + // Authoritative check-and-record, atomic on the main actor so two + // concurrent detached tasks can't both process the same event. + let alreadyProcessed: Bool = await MainActor.run { + if context.hasProcessedNostrEvent(giftWrap.id) { return true } + context.recordProcessedNostrEvent(giftWrap.id) + return false + } + if alreadyProcessed { return } let currentIdentity: NostrIdentity? = await MainActor.run { context.currentNostrIdentity() } diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 4af153e0..b446000a 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -411,11 +411,23 @@ struct ChatViewModelNostrExtensionTests { var invalidGiftWrap = giftWrap invalidGiftWrap.sig = String(repeating: "0", count: 128) + // Verification and recording now happen on a detached task; give the + // invalid copy time to be verified-and-rejected, then assert it never + // entered the dedup set. viewModel.handleNostrMessage(invalidGiftWrap) + try await Task.sleep(nanoseconds: 150_000_000) #expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id)) viewModel.handleNostrMessage(giftWrap) - #expect(viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id)) + var recorded = false + for _ in 0..<200 { + if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { + recorded = true + break + } + try await Task.sleep(nanoseconds: 10_000_000) + } + #expect(recorded) } @Test @MainActor diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift new file mode 100644 index 00000000..14b08f10 --- /dev/null +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -0,0 +1,505 @@ +// +// PerformanceBaselineTests.swift +// bitchatTests +// +// Performance baselines for hot paths so regressions are measured, not +// guessed. Uses XCTest `measure {}` (swift-testing has no equivalent) with +// NO recorded baselines โ€” these tests record timing in CI logs but never +// fail on timing, so they cannot flake. +// +// Each benchmark builds its fixtures OUTSIDE the measure block with fixed +// seeds/content so every iteration does deterministic work: no network, +// no Tor, no sleeps. +// +// Skippable via the BITCHAT_SKIP_PERF_BASELINES=1 environment variable; +// runs by default. +// + +import XCTest +import SwiftUI +import BitFoundation +@testable import bitchat + +@MainActor +final class PerformanceBaselineTests: XCTestCase { + + override func setUpWithError() throws { + try XCTSkipIf( + ProcessInfo.processInfo.environment["BITCHAT_SKIP_PERF_BASELINES"] == "1", + "Performance baselines skipped via BITCHAT_SKIP_PERF_BASELINES" + ) + } + + override func tearDown() { + // Drain main-queue tasks spawned by coordinators (handlePublicMessage + // hops etc.) so they don't bleed into the next test's measure block. + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + super.tearDown() + } + + /// Reports one human-readable throughput line per benchmark so CI logs + /// are readable without parsing XCTest's measure output. + private func reportThroughput(_ name: String, samples: [TimeInterval], operations: Int, unit: String) { + guard !samples.isEmpty else { return } + let avg = samples.reduce(0, +) / Double(samples.count) + let opsPerSec = avg > 0 ? Double(operations) / avg : .infinity + print(String( + format: "PERF[%@]: %.0f %@/sec (avg %.3f ms per pass of %d, %d passes)", + name, opsPerSec, unit, avg * 1000, operations, samples.count + )) + } + + // MARK: - 1a. Nostr inbound event handling (fresh events) + + /// `ChatNostrCoordinator.handleNostrEvent` for never-seen geo events + /// (kind 20000): signature verification, dedup record, presence/nickname + /// bookkeeping, and public-message ingest scheduling. + func testNostrInboundEventHandling_freshEvents() throws { + let events = try Self.makeSignedGeohashEvents(count: 500) + // A fresh context per measure pass so every event takes the + // first-seen path on every iteration. Kept alive so the weakly + // captured Task hops stay valid. + var keepAlive: [(PerfNostrContext, ChatNostrCoordinator)] = [] + var samples: [TimeInterval] = [] + + measure { + let context = PerfNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + keepAlive.append((context, coordinator)) + let start = Date() + for event in events { + coordinator.handleNostrEvent(event) + } + samples.append(Date().timeIntervalSince(start)) + XCTAssertEqual(context.processedEventCount, events.count) + } + + reportThroughput("nostrInbound.fresh", samples: samples, operations: events.count, unit: "events") + } + + // MARK: - 1b. Nostr inbound event handling (duplicate events) + + /// The dedup-hit path: identical events replayed. Duplicates dominate + /// real relay traffic (the same event arrives from several relays), so + /// this path runs hundreds of times a minute in busy geohashes. Note it + /// still pays full Schnorr signature verification before the dedup check. + func testNostrInboundEventHandling_duplicateEvents() throws { + let events = try Self.makeSignedGeohashEvents(count: 500) + let context = PerfNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + // Pre-warm: every event is now recorded as processed. + for event in events { + coordinator.handleNostrEvent(event) + } + XCTAssertEqual(context.processedEventCount, events.count) + var samples: [TimeInterval] = [] + + measure { + let start = Date() + for event in events { + coordinator.handleNostrEvent(event) + } + samples.append(Date().timeIntervalSince(start)) + } + + // Dedup held: nothing was re-processed. + XCTAssertEqual(context.processedEventCount, events.count) + reportThroughput("nostrInbound.duplicate", samples: samples, operations: events.count, unit: "events") + } + + // MARK: - 2. BLE inbound packet pipeline (decode + dedup) + + /// Binary encode/decode round trip plus `MessageDeduplicator` at + /// realistic mesh sizes: 1000 packets with 100-300 byte payloads, each + /// dedup-checked twice (first-seen insert, then duplicate hit) the way + /// relayed packets arrive on multiple links. + func testBLEInboundPacketPipeline() throws { + var rng = SeededGenerator(seed: 0xB17C4A7) + let baseTimestamp = UInt64(1_700_000_000_000) + var packets: [BitchatPacket] = [] + var dedupIDs: [String] = [] + packets.reserveCapacity(1000) + dedupIDs.reserveCapacity(1000) + for index in 0..<1000 { + let senderID = Data((0..<8).map { _ in UInt8.random(in: 0...255, using: &rng) }) + let payloadSize = Int.random(in: 100...300, using: &rng) + let payload = Data((0.. delivered so every call + /// performs a real update (never the skip path). + func testDeliveryStatusIncrementalUpdates() { + let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40) + let coordinator = ChatDeliveryCoordinator(context: context) + // Warm the index so the measure block exercises the incremental path. + XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent)) + + // 250 public + 250 private IDs spread across the corpus. + var targetIDs: [String] = [] + for i in stride(from: 0, to: 2000, by: 8) { targetIDs.append(context.messages[i].id) } + let peerIDs = context.privateChats.keys.sorted { $0.id < $1.id } + for (offset, peer) in peerIDs.enumerated() where offset < 25 { + guard let chat = context.privateChats[peer] else { continue } + for i in stride(from: 0, to: chat.count, by: 4) { targetIDs.append(chat[i].id) } + } + XCTAssertEqual(targetIDs.count, 500) + + let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) + var toggle = false + var samples: [TimeInterval] = [] + + measure { + toggle.toggle() + let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent + let start = Date() + var updated = 0 + for id in targetIDs where coordinator.updateMessageDeliveryStatus(id, status: status) { + updated += 1 + } + samples.append(Date().timeIntervalSince(start)) + XCTAssertEqual(updated, targetIDs.count) + } + + reportThroughput("delivery.incrementalUpdate", samples: samples, operations: targetIDs.count, unit: "updates") + } + + // MARK: - 4b. Delivery location index (rebuild path) + + /// The expensive path: a non-append insertion (out-of-order arrival at + /// the head of the timeline) invalidates the index, so the next status + /// update triggers a full rebuild over ~4000 message locations. + func testDeliveryStatusIndexRebuild() { + let context = PerfDeliveryContext.makeCorpus(publicCount: 2000, peerCount: 50, messagesPerPeer: 40) + let coordinator = ChatDeliveryCoordinator(context: context) + // Warm the index before the first insertion invalidates it. + XCTAssertTrue(coordinator.updateMessageDeliveryStatus(context.messages[0].id, status: .sent)) + + // Pre-built head insertions, one consumed per measure pass. + let insertions: [BitchatMessage] = (0..<32).map { i in + BitchatMessage( + id: "insert-\(i)", + sender: "alice", + content: "out-of-order arrival \(i)", + timestamp: Date(timeIntervalSince1970: 1_690_000_000), + isRelay: false + ) + } + let probeID = context.messages[1000].id + let fixedDate = Date(timeIntervalSince1970: 1_700_000_000) + var pass = 0 + var toggle = false + var samples: [TimeInterval] = [] + + measure { + precondition(pass < insertions.count, "add more pre-built insertions") + context.messages.insert(insertions[pass], at: 0) + pass += 1 + toggle.toggle() + let status: DeliveryStatus = toggle ? .delivered(to: "peer", at: fixedDate) : .sent + let start = Date() + let updated = coordinator.updateMessageDeliveryStatus(probeID, status: status) + samples.append(Date().timeIntervalSince(start)) + XCTAssertTrue(updated) + } + + reportThroughput("delivery.indexRebuild", samples: samples, operations: 1, unit: "rebuilds") + } + + // MARK: - 5. Message formatting + + /// `MessageFormattingEngine.formatMessage` over 200 messages with + /// mentions, hashtags, and URLs. Formatting caches per message, so each + /// measure pass consumes a fresh pre-built batch (cache-miss path, which + /// is the cost paid when messages first render). + func testMessageFormatting() { + let context = PerfFormattingContext(nickname: "carol") + let batchCount = 16 // > XCTest's default 10 measure iterations + let batchSize = 200 + let contents: [(String, [String]?)] = [ + ("hello mesh, anyone around tonight?", nil), + ("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]), + ("checking in from the harbor #bitchat #mesh", nil), + ("@bob#0042 ping me when you get this", ["bob#0042"]), + ("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil), + ] + let batches: [[BitchatMessage]] = (0.. [NostrEvent] { + let senders = try (0..<8).map { _ in try NostrIdentity.generate() } + let lines = [ + "hello from the geohash", + "anyone near the station?", + "@bob#0042 are you on mesh too?", + "check this out https://example.com/p/123 #bitchat", + "teleport check, who's local?", + ] + return try (0.. UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } +} + +// MARK: - Mock ChatNostrContext + +/// Minimal `ChatNostrContext` for benchmarking `ChatNostrCoordinator` +/// without a `ChatViewModel` (mirrors `ChatNostrCoordinatorContextTests`). +/// Callbacks are cheap dictionary/array operations so the measured cost is +/// the coordinator's own pipeline. +@MainActor +private final class PerfNostrContext: ChatNostrContext { + var activeChannel: ChannelID = .location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd")) + var currentGeohash: String? = "u4pruyd" + var geoSubscriptionID: String? + var geoDmSubscriptionID: String? + var geoSamplingSubs: [String: String] = [:] + var lastGeoNotificationAt: [String: Date] = [:] + var nostrRelayManager: NostrRelayManager? { nil } + + var messages: [BitchatMessage] = [] + func resetPublicMessagePipeline() {} + func updatePublicMessagePipelineChannel(_ channel: ChannelID) {} + func refreshVisibleMessages(from channel: ChannelID?) {} + func addPublicSystemMessage(_ content: String) {} + func drainPendingGeohashSystemMessages() -> [String] { [] } + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { true } + func synchronizePublicConversationStore(forGeohash geohash: String) {} + + private(set) var handledPublicMessageCount = 0 + func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessageCount += 1 } + func checkForMentions(_ message: BitchatMessage) {} + func sendHapticFeedback(for message: BitchatMessage) {} + func parseMentions(from content: String) -> [String] { + MessageFormattingEngine.extractMentions(from: content) + } + + var selectedPrivateChatPeer: PeerID? + var nostrKeyMapping: [PeerID: String] = [:] + func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {} + func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} + func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} + func startPrivateChat(with peerID: PeerID) {} + + private struct NoIdentity: Error {} + var geohashIdentities: [String: NostrIdentity] = [:] + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { + guard let identity = geohashIdentities[geohash] else { throw NoIdentity() } + return identity + } + func currentNostrIdentity() -> NostrIdentity? { nil } + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool { false } + func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { "anon#\(pubkeyHex.prefix(4))" } + + private var processedNostrEventIDs: Set = [] + var processedEventCount: Int { processedNostrEventIDs.count } + func hasProcessedNostrEvent(_ eventID: String) -> Bool { processedNostrEventIDs.contains(eventID) } + func recordProcessedNostrEvent(_ eventID: String) { processedNostrEventIDs.insert(eventID) } + func clearProcessedNostrEvents() { processedNostrEventIDs.removeAll() } + + var geoNicknames: [String: String] = [:] + private var teleportedKeys: Set = [] + var teleportedGeoCount: Int { teleportedKeys.count } + func startGeoParticipantRefreshTimer() {} + func stopGeoParticipantRefreshTimer() {} + func setActiveParticipantGeohash(_ geohash: String?) {} + private(set) var participantRecords = 0 + func recordGeoParticipant(pubkeyHex: String) { participantRecords += 1 } + func recordGeoParticipant(pubkeyHex: String, geohash: String) { participantRecords += 1 } + func geoParticipantCount(for geohash: String) -> Int { 0 } + func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname } + func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) } + func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) } + func clearTeleportedGeo() { teleportedKeys.removeAll() } + func clearGeoNicknames() { geoNicknames.removeAll() } + func visibleGeohashPeople() -> [GeoPerson] { [] } + + var isTeleported = false + func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { false } + + func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {} + func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {} + func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {} +} + +// MARK: - Mock ChatDeliveryContext + +@MainActor +private final class PerfDeliveryContext: ChatDeliveryContext { + var messages: [BitchatMessage] = [] + var privateChats: [PeerID: [BitchatMessage]] = [:] + var sentReadReceipts: Set = [] + var isStartupPhase: Bool { false } + func notifyUIChanged() {} + func markMessageDelivered(_ messageID: String) {} + + /// 2000 public + `peerCount` x `messagesPerPeer` private messages with + /// deterministic IDs and timestamps. + static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext { + let context = PerfDeliveryContext() + let base = Date(timeIntervalSince1970: 1_700_000_000) + context.messages = (0.. Bool { false } + func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { .blue } + func peerURL(for peerID: PeerID) -> URL? { URL(string: "bitchat://peer/\(peerID.id)") } +} From 707b22878d5be1cb3c66d20788b7c39fbcb44811 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Jun 2026 21:26:21 +0200 Subject: [PATCH 3/9] Convert shared coordinator state to owner-side intent operations nostrKeyMapping, sentGeoDeliveryAcks/sentReadReceipts dedupe, isBatchingPublic, geo subscription lifecycle, and private-chat selection hand-off now mutate through single intent operations on ChatViewModel, with backing storage locked down via private(set) so the single-writer property is compiler-enforced. Context protocols downgrade to read-only access where reads remain. 8 new contract tests. Known remaining writers outside the protocols: sentReadReceipts is passed inout to PrivateChatManager.syncReadReceiptsForSentMessages and un-marked by ChatTransportEventCoordinator on disconnect. Co-Authored-By: Claude Fable 5 --- .../ViewModels/ChatDeliveryCoordinator.swift | 10 +- bitchat/ViewModels/ChatNostrCoordinator.swift | 54 +++++--- .../ChatPeerIdentityCoordinator.swift | 4 +- .../ChatPrivateConversationCoordinator.swift | 40 +++--- .../ChatPublicConversationCoordinator.swift | 17 ++- bitchat/ViewModels/ChatViewModel.swift | 105 +++++++++++++- .../ChatNostrCoordinatorContextTests.swift | 11 ++ ...eConversationCoordinatorContextTests.swift | 15 ++ ...cConversationCoordinatorContextTests.swift | 12 +- .../ChatViewModelDeliveryStatusTests.swift | 6 + .../ChatViewModelExtensionsTests.swift | 131 +++++++++++++++++- .../PerformanceBaselineTests.swift | 17 +++ 12 files changed, 358 insertions(+), 64 deletions(-) diff --git a/bitchat/ViewModels/ChatDeliveryCoordinator.swift b/bitchat/ViewModels/ChatDeliveryCoordinator.swift index 62492aa2..61a4c8e3 100644 --- a/bitchat/ViewModels/ChatDeliveryCoordinator.swift +++ b/bitchat/ViewModels/ChatDeliveryCoordinator.swift @@ -14,8 +14,11 @@ import Foundation protocol ChatDeliveryContext: AnyObject { var messages: [BitchatMessage] { get set } var privateChats: [PeerID: [BitchatMessage]] { get set } - var sentReadReceipts: Set { get set } var isStartupPhase: Bool { get } + /// Drops every recorded read receipt whose message ID is not in `validMessageIDs`. + /// Returns the number of receipts removed. (Single mutation path for the + /// owner's `sentReadReceipts`; this coordinator never reads the raw set.) + func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). func notifyUIChanged() /// Confirms receipt so the message router stops retaining the message for resend. @@ -57,10 +60,7 @@ final class ChatDeliveryCoordinator { } ) - let oldCount = context.sentReadReceipts.count - context.sentReadReceipts = context.sentReadReceipts.intersection(validMessageIDs) - - let removedCount = oldCount - context.sentReadReceipts.count + let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs) if removedCount > 0 { SecureLogger.debug("๐Ÿงน Cleaned up \(removedCount) old read receipts", category: .session) } diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index cf9b24b9..96b35783 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -18,10 +18,17 @@ protocol ChatNostrContext: AnyObject { // MARK: Channel & subscription state var activeChannel: ChannelID { get set } var currentGeohash: String? { get set } - var geoSubscriptionID: String? { get set } - var geoDmSubscriptionID: String? { get set } + var geoSubscriptionID: String? { get } + var geoDmSubscriptionID: String? { get } + func setGeoChatSubscriptionID(_ id: String?) + func setGeoDmSubscriptionID(_ id: String?) /// Geohash sampling subscriptions: subscription ID -> geohash. - var geoSamplingSubs: [String: String] { get set } + var geoSamplingSubs: [String: String] { get } + func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) + func removeGeoSamplingSub(_ subID: String) + /// Clears all sampling subscriptions and returns the removed subscription IDs + /// so the caller can unsubscribe them from the relay manager. + func clearGeoSamplingSubs() -> [String] /// Per-geohash notification cooldown: geohash -> last notify time. var lastGeoNotificationAt: [String: Date] { get set } var nostrRelayManager: NostrRelayManager? { get } @@ -44,7 +51,9 @@ protocol ChatNostrContext: AnyObject { // MARK: Inbound private (geohash DM) payloads var selectedPrivateChatPeer: PeerID? { get } - var nostrKeyMapping: [PeerID: String] { get set } + var nostrKeyMapping: [PeerID: String] { get } + /// Records the Nostr pubkey behind a (possibly virtual) peer ID. + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) func handlePrivateMessage( _ payload: NoisePayload, senderPubkey: String, @@ -225,12 +234,12 @@ final class ChatNostrCoordinator { if let dmSub = context.geoDmSubscriptionID { NostrRelayManager.shared.unsubscribe(id: dmSub) - context.geoDmSubscriptionID = nil + context.setGeoDmSubscriptionID(nil) } if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { let dmSub = "geo-dm-\(channel.geohash)" - context.geoDmSubscriptionID = dmSub + context.setGeoDmSubscriptionID(dmSub) let dmFilter = NostrFilter.giftWrapsFor( pubkey: identity.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) @@ -274,8 +283,8 @@ final class ChatNostrCoordinator { context.setGeoNickname(nick, forPubkey: event.pubkey) } - context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey - context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) context.recordGeoParticipant(pubkeyHex: event.pubkey) if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { @@ -349,7 +358,7 @@ final class ChatNostrCoordinator { let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) let convKey = PeerID(nostr_: senderPubkey) - context.nostrKeyMapping[convKey] = senderPubkey + context.registerNostrKeyMapping(senderPubkey, for: convKey) switch noisePayload.type { case .privateMessage: @@ -400,11 +409,11 @@ final class ChatNostrCoordinator { if let sub = context.geoSubscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) - context.geoSubscriptionID = nil + context.setGeoChatSubscriptionID(nil) } if let dmSub = context.geoDmSubscriptionID { NostrRelayManager.shared.unsubscribe(id: dmSub) - context.geoDmSubscriptionID = nil + context.setGeoDmSubscriptionID(nil) } context.currentGeohash = nil context.setActiveParticipantGeohash(nil) @@ -429,7 +438,7 @@ final class ChatNostrCoordinator { } let subID = "geo-\(channel.geohash)" - context.geoSubscriptionID = subID + context.setGeoChatSubscriptionID(subID) context.startGeoParticipantRefreshTimer() let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) @@ -504,8 +513,8 @@ final class ChatNostrCoordinator { context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey) } - context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey - context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { return @@ -547,7 +556,7 @@ final class ChatNostrCoordinator { guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return } let dmSub = "geo-dm-\(channel.geohash)" - context.geoDmSubscriptionID = dmSub + context.setGeoDmSubscriptionID(dmSub) if TorManager.shared.isReady { SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))โ€ฆ sub=\(dmSub)", category: .session) } @@ -593,7 +602,7 @@ final class ChatNostrCoordinator { } let convKey = PeerID(nostr_: senderPubkey) - context.nostrKeyMapping[convKey] = senderPubkey + context.registerNostrKeyMapping(senderPubkey, for: convKey) switch payload.type { case .privateMessage: @@ -633,7 +642,7 @@ final class ChatNostrCoordinator { } context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) - context.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex + context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) SecureLogger.debug( "GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))โ€ฆ teleported=\(geoContext.teleported)", category: .session @@ -666,7 +675,7 @@ final class ChatNostrCoordinator { for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) { NostrRelayManager.shared.unsubscribe(id: subID) - context.geoSamplingSubs.removeValue(forKey: subID) + context.removeGeoSamplingSub(subID) } for gh in toAdd { @@ -678,7 +687,7 @@ final class ChatNostrCoordinator { func subscribe(_ gh: String) { guard let context else { return } let subID = "geo-sample-\(gh)" - context.geoSamplingSubs[subID] = gh + context.addGeoSamplingSub(subID, forGeohash: gh) let filter = NostrFilter.geohashEphemeral( gh, since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), @@ -771,10 +780,9 @@ final class ChatNostrCoordinator { @MainActor func endGeohashSampling() { guard let context else { return } - for subID in context.geoSamplingSubs.keys { + for subID in context.clearGeoSamplingSubs() { NostrRelayManager.shared.unsubscribe(id: subID) } - context.geoSamplingSubs.removeAll() clearGeoSamplingEventDedup() } @@ -885,7 +893,7 @@ final class ChatNostrCoordinator { let payload = NoisePayload.decode(packet.payload) { let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) await MainActor.run { - context.nostrKeyMapping[targetPeerID] = senderPubkey + context.registerNostrKeyMapping(senderPubkey, for: targetPeerID) switch payload.type { case .privateMessage: @@ -1063,7 +1071,7 @@ final class ChatNostrCoordinator { func startGeohashDM(withPubkeyHex hex: String) { guard let context else { return } let convKey = PeerID(nostr_: hex) - context.nostrKeyMapping[convKey] = hex + context.registerNostrKeyMapping(hex, for: convKey) context.startPrivateChat(with: convKey) } diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 6a3047cb..372c7956 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -301,7 +301,7 @@ final class ChatPeerIdentityCoordinator { .visibleGeohashPeople() .first(where: { $0.displayName == nickname }) { let conversationKey = PeerID(nostr_: person.id) - viewModel.nostrKeyMapping[conversationKey] = person.id + viewModel.registerNostrKeyMapping(person.id, for: conversationKey) return conversationKey } @@ -312,7 +312,7 @@ final class ChatPeerIdentityCoordinator { .lowercased() ?? nickname.lowercased() if let pubkey = viewModel.geoNicknames.first(where: { $0.value.lowercased() == base })?.key { let conversationKey = PeerID(nostr_: pubkey) - viewModel.nostrKeyMapping[conversationKey] = pubkey + viewModel.registerNostrKeyMapping(pubkey, for: conversationKey) return conversationKey } diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index 66dc766b..ea1d28e0 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -15,13 +15,23 @@ import Foundation protocol ChatPrivateConversationContext: AnyObject { // MARK: Conversation state var privateChats: [PeerID: [BitchatMessage]] { get set } - var sentReadReceipts: Set { get set } - var sentGeoDeliveryAcks: Set { get set } + var sentReadReceipts: Set { get } var unreadPrivateMessages: Set { get set } - var selectedPrivateChatPeer: PeerID? { get set } + var selectedPrivateChatPeer: PeerID? { get } var nickname: String { get } var activeChannel: ChannelID { get } var nostrKeyMapping: [PeerID: String] { get } + /// Records that a read receipt is being sent for `messageID`. + /// Returns `false` when one was already recorded โ€” the caller must skip sending. + @discardableResult + func markReadReceiptSent(_ messageID: String) -> Bool + /// Records that a GeoDM delivery ACK is being sent for `messageID`. + /// Returns `false` when one was already recorded โ€” the caller must skip sending. + @discardableResult + func markGeoDeliveryAckSent(_ messageID: String) -> Bool + /// Moves the open private chat to `newPeerID` when the current selection is + /// one of the peer IDs being migrated away. + func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). func notifyUIChanged() @@ -62,8 +72,10 @@ protocol ChatPrivateConversationContext: AnyObject { } extension ChatViewModel: ChatPrivateConversationContext { - // `privateChats`, `sentReadReceipts`, and `notifyUIChanged()` are shared - // requirements with `ChatDeliveryContext`; the remaining state members are + // `privateChats` and `notifyUIChanged()` are shared requirements with + // `ChatDeliveryContext`; the single-writer intent ops (`markReadReceiptSent`, + // `markGeoDeliveryAckSent`, `handOffSelectedPrivateChat`) live next to their + // backing state in `ChatViewModel`. The remaining state members are // satisfied by existing `ChatViewModel` properties and methods. var myPeerID: PeerID { meshService.myPeerID } @@ -425,15 +437,13 @@ final class ChatPrivateConversationCoordinator { } func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { - guard !context.sentGeoDeliveryAcks.contains(messageId) else { return } + guard context.markGeoDeliveryAckSent(messageId) else { return } context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id) - context.sentGeoDeliveryAcks.insert(messageId) } func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) { - guard !context.sentReadReceipts.contains(messageId) else { return } + guard context.markReadReceiptSent(messageId) else { return } context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id) - context.sentReadReceipts.insert(messageId) } func handlePrivateMessage( @@ -579,7 +589,7 @@ final class ChatPrivateConversationCoordinator { readerNickname: context.nickname ) context.sendMeshReadReceipt(receipt, to: peerID) - context.sentReadReceipts.insert(message.id) + context.markReadReceiptSent(message.id) } else { context.unreadPrivateMessages.insert(peerID) NotificationService.shared.sendPrivateMessageNotification( @@ -654,10 +664,10 @@ final class ChatPrivateConversationCoordinator { ) SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))โ€ฆ via router", category: .session) context.routeReadReceipt(receipt, to: PeerID(hexData: key)) - context.sentReadReceipts.insert(message.id) + context.markReadReceiptSent(message.id) } else if let identity = context.currentNostrIdentity() { context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) - context.sentReadReceipts.insert(message.id) + context.markReadReceiptSent(message.id) SecureLogger.debug( "Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))โ€ฆ for mid=\(message.id.prefix(8))โ€ฆ", category: .session @@ -802,17 +812,13 @@ final class ChatPrivateConversationCoordinator { } if !oldPeerIDsToRemove.isEmpty { - let needsSelectedUpdate = oldPeerIDsToRemove.contains { context.selectedPrivateChatPeer == $0 } - for oldID in oldPeerIDsToRemove { context.privateChats.removeValue(forKey: oldID) context.unreadPrivateMessages.remove(oldID) context.clearStoredFingerprint(for: oldID) } - if needsSelectedUpdate { - context.selectedPrivateChatPeer = peerID - } + context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID) } if !migratedMessages.isEmpty { diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index f140922e..c7627ca3 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -25,7 +25,10 @@ protocol ChatPublicConversationContext: AnyObject { var currentGeohash: String? { get } var nickname: String { get } var myPeerID: PeerID { get } - var isBatchingPublic: Bool { get set } + /// Publishes the public-timeline batching state (UI animation suppression). + /// (Single mutation path for the owner's `isBatchingPublic`; this + /// coordinator never reads it.) + func setPublicBatching(_ isBatching: Bool) /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). func notifyUIChanged() func trimMessagesIfNeeded() @@ -56,7 +59,9 @@ protocol ChatPublicConversationContext: AnyObject { // MARK: Geohash participants & presence var geoNicknames: [String: String] { get } var isTeleported: Bool { get } - var nostrKeyMapping: [PeerID: String] { get set } + var nostrKeyMapping: [PeerID: String] { get } + /// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey. + func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) func visibleGeoPeople() -> [GeoPerson] func geoParticipantCount(for geohash: String) -> Int func removeGeoParticipant(pubkeyHex: String) @@ -88,7 +93,7 @@ protocol ChatPublicConversationContext: AnyObject { extension ChatViewModel: ChatPublicConversationContext { // `messages`, `privateChats`, `unreadPrivateMessages`, `nostrKeyMapping`, // `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`, - // `myPeerID`, `isTeleported`, `isBatchingPublic`, `notifyUIChanged()`, + // `myPeerID`, `isTeleported`, `notifyUIChanged()`, // `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`, // `deriveNostrIdentity(forGeohash:)`, and // `appendGeohashMessageIfAbsent(_:toGeohash:)` are shared requirements @@ -247,9 +252,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { context.unreadPrivateMessages = unread } - for (key, value) in context.nostrKeyMapping where value.lowercased() == hex { - context.nostrKeyMapping.removeValue(forKey: key) - } + context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex) addSystemMessage( String( @@ -616,7 +619,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) { - context.isBatchingPublic = isBatching + context.setPublicBatching(isBatching) } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 090a8afd..23667420 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -292,8 +292,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele objectWillChange.send() } } - var geoSubscriptionID: String? = nil - var geoDmSubscriptionID: String? = nil + // Single-writer: mutate only via `setGeoChatSubscriptionID(_:)` / `setGeoDmSubscriptionID(_:)` below. + private(set) var geoSubscriptionID: String? = nil + private(set) var geoDmSubscriptionID: String? = nil var currentGeohash: String? { get { locationPresenceStore.currentGeohash } set { locationPresenceStore.setCurrentGeohash(newValue) } @@ -366,7 +367,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele set { locationPresenceStore.replaceTeleportedGeo(newValue) } } // lowercased pubkey hex // Sampling subscriptions for multiple geohashes (when channel sheet is open) - var geoSamplingSubs: [String: String] = [:] // subID -> geohash + // Single-writer: mutate only via `addGeoSamplingSub` / `removeGeoSamplingSub` / `clearGeoSamplingSubs` below. + private(set) var geoSamplingSubs: [String: String] = [:] // subID -> geohash var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time // MARK: - Message Delivery Tracking @@ -384,7 +386,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele // MARK: - Public message batching (UI perf) let publicMessagePipeline: PublicMessagePipeline - @Published var isBatchingPublic: Bool = false + // Single-writer: mutate only via `setPublicBatching(_:)` below. + @Published private(set) var isBatchingPublic: Bool = false // Track sent read receipts to avoid duplicates (persisted across launches) // Note: Persistence happens automatically in didSet, no lifecycle observers needed @@ -403,7 +406,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele } // Track which GeoDM messages we've already sent a delivery ACK for (by messageID) - var sentGeoDeliveryAcks: Set = [] + // Single-writer: mutate only via `markGeoDeliveryAckSent(_:)` below. + private(set) var sentGeoDeliveryAcks: Set = [] // Track app startup phase to prevent marking old messages as unread var isStartupPhase = true @@ -411,7 +415,96 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele var torInitialReadyAnnounced: Bool = false // Track Nostr pubkey mappings for unknown senders - var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey + // Single-writer: mutate only via `registerNostrKeyMapping` / `removeNostrKeyMappings` below. + private(set) var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey + + // MARK: - Single-Writer Intent Operations + // Owner-side mutation paths for state the coordinator contexts may read + // but not write directly. Each op is the sole way to mutate its backing + // state, so check-then-mutate races between coordinators cannot occur. + + /// Records the Nostr pubkey behind a (possibly virtual) peer ID. + @MainActor + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { + nostrKeyMapping[peerID] = pubkey + } + + /// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey. + @MainActor + func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) { + for (key, value) in nostrKeyMapping where value.lowercased() == hex { + nostrKeyMapping.removeValue(forKey: key) + } + } + + /// Records that a read receipt is being sent for `messageID`. + /// Returns `false` when one was already recorded โ€” the caller must skip sending. + @MainActor + @discardableResult + func markReadReceiptSent(_ messageID: String) -> Bool { + sentReadReceipts.insert(messageID).inserted + } + + /// Records that a GeoDM delivery ACK is being sent for `messageID`. + /// Returns `false` when one was already recorded โ€” the caller must skip sending. + @MainActor + @discardableResult + func markGeoDeliveryAckSent(_ messageID: String) -> Bool { + sentGeoDeliveryAcks.insert(messageID).inserted + } + + /// Drops every recorded read receipt whose message ID is no longer valid. + /// Returns the number of receipts removed. + @MainActor + func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int { + let oldCount = sentReadReceipts.count + sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) + return oldCount - sentReadReceipts.count + } + + /// Publishes the public-timeline batching state (UI animation suppression). + @MainActor + func setPublicBatching(_ isBatching: Bool) { + isBatchingPublic = isBatching + } + + @MainActor + func setGeoChatSubscriptionID(_ id: String?) { + geoSubscriptionID = id + } + + @MainActor + func setGeoDmSubscriptionID(_ id: String?) { + geoDmSubscriptionID = id + } + + @MainActor + func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { + geoSamplingSubs[subID] = geohash + } + + @MainActor + func removeGeoSamplingSub(_ subID: String) { + geoSamplingSubs.removeValue(forKey: subID) + } + + /// Clears all sampling subscriptions and returns the removed subscription IDs + /// so the caller can unsubscribe them from the relay manager. + @MainActor + func clearGeoSamplingSubs() -> [String] { + let subIDs = Array(geoSamplingSubs.keys) + geoSamplingSubs.removeAll() + return subIDs + } + + /// Moves the open private chat to `newPeerID` when the current selection is + /// one of the peer IDs being migrated away (side-effectful: re-targets the + /// private chat session and resyncs the conversation stores). + @MainActor + func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) { + guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return } + selectedPrivateChatPeer = newPeerID + } // MARK: - Initialization diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 73ad3b96..30d208e4 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -28,6 +28,16 @@ private final class MockChatNostrContext: ChatNostrContext { var lastGeoNotificationAt: [String: Date] = [:] var nostrRelayManager: NostrRelayManager? { nil } + func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id } + func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id } + func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash } + func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) } + + func clearGeoSamplingSubs() -> [String] { + defer { geoSamplingSubs.removeAll() } + return Array(geoSamplingSubs.keys) + } + // Public timeline & pipeline var messages: [BitchatMessage] = [] private(set) var pipelineResetCount = 0 @@ -71,6 +81,7 @@ private final class MockChatNostrContext: ChatNostrContext { // Inbound private (geohash DM) payloads var selectedPrivateChatPeer: PeerID? var nostrKeyMapping: [PeerID: String] = [:] + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey } private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = [] private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = [] private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = [] diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index dc55fdca..e8f9493e 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -29,6 +29,21 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC var nostrKeyMapping: [PeerID: String] = [:] private(set) var notifyUIChangedCount = 0 + @discardableResult + func markReadReceiptSent(_ messageID: String) -> Bool { + sentReadReceipts.insert(messageID).inserted + } + + @discardableResult + func markGeoDeliveryAckSent(_ messageID: String) -> Bool { + sentGeoDeliveryAcks.insert(messageID).inserted + } + + func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) { + guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return } + selectedPrivateChatPeer = newPeerID + } + func notifyUIChanged() { notifyUIChangedCount += 1 } diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index 68322fd3..6051ccca 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -31,10 +31,14 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon var currentGeohash: String? var nickname = "me" var myPeerID = PeerID(str: "0011223344556677") - var isBatchingPublic = false + private(set) var isBatchingPublic = false private(set) var notifyUIChangedCount = 0 private(set) var trimMessagesCount = 0 + func setPublicBatching(_ isBatching: Bool) { + isBatchingPublic = isBatching + } + func notifyUIChanged() { notifyUIChangedCount += 1 } @@ -147,6 +151,12 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon var geoParticipantCounts: [String: Int] = [:] private(set) var removedGeoParticipants: [String] = [] + func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) { + for (key, value) in nostrKeyMapping where value.lowercased() == hex { + nostrKeyMapping.removeValue(forKey: key) + } + } + func visibleGeoPeople() -> [GeoPerson] { geoPeople } diff --git a/bitchatTests/ChatViewModelDeliveryStatusTests.swift b/bitchatTests/ChatViewModelDeliveryStatusTests.swift index 60d0b86d..9730c053 100644 --- a/bitchatTests/ChatViewModelDeliveryStatusTests.swift +++ b/bitchatTests/ChatViewModelDeliveryStatusTests.swift @@ -388,6 +388,12 @@ private final class MockChatDeliveryContext: ChatDeliveryContext { private(set) var notifyUIChangedCount = 0 private(set) var markedDeliveredMessageIDs: [String] = [] + func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int { + let oldCount = sentReadReceipts.count + sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) + return oldCount - sentReadReceipts.count + } + func notifyUIChanged() { notifyUIChangedCount += 1 } diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index b446000a..666745f2 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -225,7 +225,7 @@ struct ChatViewModelPrivateChatExtensionTests { // "Check geohash (Nostr) blocks using mapping to full pubkey" let hexPubkey = "0000000000000000000000000000000000000000000000000000000000000001" - viewModel.nostrKeyMapping[blockedPeerID] = hexPubkey + viewModel.registerNostrKeyMapping(hexPubkey, for: blockedPeerID) viewModel.identityManager.setNostrBlocked(hexPubkey, isBlocked: true) // Force isGeoChat/isGeoDM check to be true by setting prefix? @@ -235,7 +235,7 @@ struct ChatViewModelPrivateChatExtensionTests { // We need a peerID that looks like geo. let geoPeerID = PeerID(nostr_: hexPubkey) - viewModel.nostrKeyMapping[geoPeerID] = hexPubkey + viewModel.registerNostrKeyMapping(hexPubkey, for: geoPeerID) let geoMessage = BitchatMessage( id: "msg-geo-blocked", @@ -789,7 +789,7 @@ struct ChatViewModelGeoDMTests { let convKey = PeerID(nostr_: recipientHex) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) - viewModel.nostrKeyMapping[convKey] = recipientHex + viewModel.registerNostrKeyMapping(recipientHex, for: convKey) viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true) viewModel.sendGeohashDM("hello", to: convKey) @@ -829,6 +829,131 @@ struct ChatViewModelGeoDMTests { } } +// MARK: - Single-Writer Intent Operation Tests + +/// Contracts for the owner-side intent ops that are the sole mutation paths +/// for `ChatViewModel`'s shared coordinator state (`nostrKeyMapping`, +/// `sentReadReceipts`, `sentGeoDeliveryAcks`, `isBatchingPublic`, the geo +/// subscription IDs, and the selected private chat hand-off). +struct ChatViewModelIntentOperationTests { + + @Test @MainActor + func markGeoDeliveryAckSent_returnsFalseOnSecondCall() async { + let (viewModel, _) = makeTestableViewModel() + + #expect(viewModel.markGeoDeliveryAckSent("geo-ack-1")) + #expect(!viewModel.markGeoDeliveryAckSent("geo-ack-1")) + #expect(viewModel.markGeoDeliveryAckSent("geo-ack-2")) + #expect(viewModel.sentGeoDeliveryAcks == ["geo-ack-1", "geo-ack-2"]) + } + + @Test @MainActor + func markReadReceiptSent_returnsFalseOnSecondCall() async { + let (viewModel, _) = makeTestableViewModel() + + #expect(viewModel.markReadReceiptSent("read-1")) + #expect(!viewModel.markReadReceiptSent("read-1")) + #expect(viewModel.sentReadReceipts.contains("read-1")) + } + + @Test @MainActor + func pruneSentReadReceipts_dropsStaleIDsAndReturnsRemovedCount() async { + let (viewModel, _) = makeTestableViewModel() + viewModel.sentReadReceipts = ["keep-1", "keep-2", "drop-1", "drop-2"] + + let removed = viewModel.pruneSentReadReceipts(keeping: ["keep-1", "keep-2", "unrelated"]) + + #expect(removed == 2) + #expect(viewModel.sentReadReceipts == ["keep-1", "keep-2"]) + // Nothing stale left: a second prune removes nothing. + #expect(viewModel.pruneSentReadReceipts(keeping: ["keep-1", "keep-2"]) == 0) + } + + @Test @MainActor + func registerNostrKeyMapping_isVisibleToNostrCoordinatorLookups() async { + let (viewModel, _) = makeTestableViewModel() + let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + let convKey = PeerID(nostr_: hex) + + // Registered through the owner intent op (as e.g. the private + // conversation flow does) and resolved through the Nostr coordinator, + // which reads the same backing dictionary via `ChatNostrContext`. + viewModel.registerNostrKeyMapping(hex, for: convKey) + + #expect(viewModel.nostrKeyMapping[convKey] == hex) + #expect(viewModel.nostrCoordinator.fullNostrHex(forSenderPeerID: convKey) == hex) + } + + @Test @MainActor + func removeNostrKeyMappings_dropsEveryMappingForThePubkeyCaseInsensitively() async { + let (viewModel, _) = makeTestableViewModel() + let hex = "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899" + let otherHex = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100" + viewModel.registerNostrKeyMapping(hex.uppercased(), for: PeerID(nostr: hex)) + viewModel.registerNostrKeyMapping(hex, for: PeerID(nostr_: hex)) + viewModel.registerNostrKeyMapping(otherHex, for: PeerID(nostr_: otherHex)) + + viewModel.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex) + + #expect(viewModel.nostrKeyMapping[PeerID(nostr: hex)] == nil) + #expect(viewModel.nostrKeyMapping[PeerID(nostr_: hex)] == nil) + #expect(viewModel.nostrKeyMapping[PeerID(nostr_: otherHex)] == otherHex) + } + + @Test @MainActor + func setPublicBatching_publishesBatchingState() async { + let (viewModel, _) = makeTestableViewModel() + + #expect(!viewModel.isBatchingPublic) + viewModel.setPublicBatching(true) + #expect(viewModel.isBatchingPublic) + viewModel.setPublicBatching(false) + #expect(!viewModel.isBatchingPublic) + } + + @Test @MainActor + func geoSubscriptionIntentOps_setClearAndDrainSubscriptions() async { + let (viewModel, _) = makeTestableViewModel() + + viewModel.setGeoChatSubscriptionID("geo-u4pruy") + viewModel.setGeoDmSubscriptionID("geo-dm-u4pruy") + #expect(viewModel.geoSubscriptionID == "geo-u4pruy") + #expect(viewModel.geoDmSubscriptionID == "geo-dm-u4pruy") + + viewModel.setGeoChatSubscriptionID(nil) + viewModel.setGeoDmSubscriptionID(nil) + #expect(viewModel.geoSubscriptionID == nil) + #expect(viewModel.geoDmSubscriptionID == nil) + + viewModel.addGeoSamplingSub("geo-sample-aaaa", forGeohash: "aaaa") + viewModel.addGeoSamplingSub("geo-sample-bbbb", forGeohash: "bbbb") + viewModel.removeGeoSamplingSub("geo-sample-aaaa") + #expect(viewModel.geoSamplingSubs == ["geo-sample-bbbb": "bbbb"]) + + let cleared = viewModel.clearGeoSamplingSubs() + #expect(cleared == ["geo-sample-bbbb"]) + #expect(viewModel.geoSamplingSubs.isEmpty) + } + + @Test @MainActor + func handOffSelectedPrivateChat_movesSelectionOnlyWhenSelectedPeerIsMigrated() async { + let (viewModel, _) = makeTestableViewModel() + let oldPeer = PeerID(str: "aaaaaaaaaaaaaaaa") + let unrelatedPeer = PeerID(str: "cccccccccccccccc") + let newPeer = PeerID(str: "bbbbbbbbbbbbbbbb") + + // Selection not among the migrated peers: untouched. + viewModel.selectedPrivateChatPeer = unrelatedPeer + viewModel.handOffSelectedPrivateChat(from: [oldPeer], to: newPeer) + #expect(viewModel.selectedPrivateChatPeer == unrelatedPeer) + + // Selection being migrated away: handed off to the new peer. + viewModel.selectedPrivateChatPeer = oldPeer + viewModel.handOffSelectedPrivateChat(from: [oldPeer], to: newPeer) + #expect(viewModel.selectedPrivateChatPeer == newPeer) + } +} + @Suite(.serialized) struct ChatViewModelMediaTransferTests { diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 14b08f10..34ef1918 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -384,6 +384,16 @@ private final class PerfNostrContext: ChatNostrContext { var lastGeoNotificationAt: [String: Date] = [:] var nostrRelayManager: NostrRelayManager? { nil } + func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id } + func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id } + func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash } + func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) } + + func clearGeoSamplingSubs() -> [String] { + defer { geoSamplingSubs.removeAll() } + return Array(geoSamplingSubs.keys) + } + var messages: [BitchatMessage] = [] func resetPublicMessagePipeline() {} func updatePublicMessagePipelineChannel(_ channel: ChannelID) {} @@ -403,6 +413,7 @@ private final class PerfNostrContext: ChatNostrContext { var selectedPrivateChatPeer: PeerID? var nostrKeyMapping: [PeerID: String] = [:] + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey } func handlePrivateMessage(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID, id: NostrIdentity, messageTimestamp: Date) {} func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {} @@ -460,6 +471,12 @@ private final class PerfDeliveryContext: ChatDeliveryContext { func notifyUIChanged() {} func markMessageDelivered(_ messageID: String) {} + func pruneSentReadReceipts(keeping validMessageIDs: Set) -> Int { + let oldCount = sentReadReceipts.count + sentReadReceipts = sentReadReceipts.intersection(validMessageIDs) + return oldCount - sentReadReceipts.count + } + /// 2000 public + `peerCount` x `messagesPerPeer` private messages with /// deterministic IDs and timestamps. static func makeCorpus(publicCount: Int, peerCount: Int, messagesPerPeer: Int) -> PerfDeliveryContext { From 82736c49919011cb958428366412c9312da0e7d2 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Jun 2026 21:54:54 +0200 Subject: [PATCH 4/9] Migrate five more coordinators to narrow context protocols ChatTransportEventCoordinator, ChatPeerIdentityCoordinator, ChatMediaTransferCoordinator, ChatVerificationCoordinator, and ChatLifecycleCoordinator drop their unowned ChatViewModel back-refs for narrow @MainActor contexts (20-36 members each), reusing shared witnesses across protocols. The two remaining raw writers of sentReadReceipts now route through owner intent ops (unmarkReadReceiptsSent, syncReadReceiptsForSentMessages), closing the gaps noted in the previous commit. NoiseEncryptionService stays fully out of the verification coordinator via installNoiseSessionCallbacks. 26 new mock-context tests. Co-Authored-By: Claude Fable 5 --- .../ViewModels/ChatLifecycleCoordinator.swift | 205 +++++++--- .../ChatMediaTransferCoordinator.swift | 138 +++++-- .../ChatPeerIdentityCoordinator.swift | 371 +++++++++++++----- .../ChatTransportEventCoordinator.swift | 254 ++++++++---- .../ChatVerificationCoordinator.swift | 267 +++++++++---- bitchat/ViewModels/ChatViewModel.swift | 29 +- ...ChatLifecycleCoordinatorContextTests.swift | 283 +++++++++++++ ...MediaTransferCoordinatorContextTests.swift | 206 ++++++++++ ...tPeerIdentityCoordinatorContextTests.swift | 358 +++++++++++++++++ ...ransportEventCoordinatorContextTests.swift | 331 ++++++++++++++++ ...tVerificationCoordinatorContextTests.swift | 284 ++++++++++++++ 11 files changed, 2376 insertions(+), 350 deletions(-) create mode 100644 bitchatTests/ChatLifecycleCoordinatorContextTests.swift create mode 100644 bitchatTests/ChatMediaTransferCoordinatorContextTests.swift create mode 100644 bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift create mode 100644 bitchatTests/ChatTransportEventCoordinatorContextTests.swift create mode 100644 bitchatTests/ChatVerificationCoordinatorContextTests.swift diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index a74b6882..4e4ad3f3 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -2,36 +2,129 @@ import BitFoundation import BitLogger import Foundation +/// The narrow surface `ChatLifecycleCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatLifecycleCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatLifecycleContext: AnyObject { + // MARK: Chat & receipt state + var messages: [BitchatMessage] { get } + var privateChats: [PeerID: [BitchatMessage]] { get set } + var unreadPrivateMessages: Set { get set } + var selectedPrivateChatPeer: PeerID? { get } + var sentReadReceipts: Set { get } + var nickname: String { get } + var myPeerID: PeerID { get } + var activeChannel: ChannelID { get } + var nostrKeyMapping: [PeerID: String] { get } + /// Records that a read receipt is being sent for `messageID`. + /// Returns `false` when one was already recorded โ€” the caller must skip sending. + @discardableResult + func markReadReceiptSent(_ messageID: String) -> Bool + /// The owner-level read pass (chat manager + receipts); used for the + /// delayed re-run after the app becomes active. + func markPrivateMessagesAsRead(from peerID: PeerID) + /// Marks the chat read in the private chat manager (sends pending mesh READ acks). + func markChatAsRead(from peerID: PeerID) + func synchronizePrivateConversationStore() + func addSystemMessage(_ content: String) + + // MARK: Peers & sessions + func peerNickname(for peerID: PeerID) -> String? + /// The peer's current entry in the unified peer service, if known. + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? + func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState + func stopMeshServices() + /// Re-reads the transport's current Bluetooth state and updates the alert UI. + func refreshBluetoothState() + + // MARK: Routing & receipts + func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) + func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) + func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) + + // MARK: Nostr & geohash + var isTeleported: Bool { get } + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity + func recordGeoParticipant(pubkeyHex: String) + + // MARK: Identity persistence + /// Forces the identity manager to persist its state now. + func forceSaveIdentity() + /// Confirms the Noise identity key is still present in the keychain. + @discardableResult + func verifyIdentityKeyExists() -> Bool +} + +extension ChatViewModel: ChatLifecycleContext { + // `messages`, `privateChats`, `unreadPrivateMessages`, + // `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`, + // `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`, + // `markPrivateMessagesAsRead(from:)`, + // `synchronizePrivateConversationStore()`, `addSystemMessage(_:)`, + // `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`, + // the routing/ack members, `isTeleported`, + // `deriveNostrIdentity(forGeohash:)`, and `recordGeoParticipant(pubkeyHex:)` + // are shared requirements with the other contexts or satisfied by + // existing `ChatViewModel` members. The members below flatten nested + // service accesses into intent-named calls. + + func markChatAsRead(from peerID: PeerID) { + privateChatManager.markAsRead(from: peerID) + } + + func stopMeshServices() { + meshService.stopServices() + } + + func refreshBluetoothState() { + if let bleService = meshService as? BLEService { + updateBluetoothState(bleService.getCurrentBluetoothState()) + } + } + + func forceSaveIdentity() { + identityManager.forceSave() + } + + @discardableResult + func verifyIdentityKeyExists() -> Bool { + keychain.verifyIdentityKeyExists() + } +} + @MainActor final class ChatLifecycleCoordinator { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatLifecycleContext - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatLifecycleContext) { + self.context = context } func handleDidBecomeActive() { - if let bleService = viewModel.meshService as? BLEService { - let currentState = bleService.getCurrentBluetoothState() - viewModel.updateBluetoothState(currentState) - } + context.refreshBluetoothState() - guard let peerID = viewModel.selectedPrivateChatPeer else { return } + guard let peerID = context.selectedPrivateChatPeer else { return } markPrivateMessagesAsRead(from: peerID) - let viewModel = self.viewModel - DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak viewModel] in + let context = self.context + DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak context] in Task { @MainActor in - viewModel?.markPrivateMessagesAsRead(from: peerID) + context?.markPrivateMessagesAsRead(from: peerID) } } } func handleScreenshotCaptured() { - let screenshotMessage = "* \(viewModel.nickname) took a screenshot *" + let screenshotMessage = "* \(context.nickname) took a screenshot *" - if let peerID = viewModel.selectedPrivateChatPeer { + if let peerID = context.selectedPrivateChatPeer { sendPrivateScreenshotNotificationIfPossible( screenshotMessage, to: peerID @@ -40,9 +133,9 @@ final class ChatLifecycleCoordinator { return } - switch viewModel.activeChannel { + switch context.activeChannel { case .mesh: - viewModel.meshService.sendMessage( + context.sendMeshMessage( screenshotMessage, mentions: [], messageID: UUID().uuidString, @@ -56,43 +149,41 @@ final class ChatLifecycleCoordinator { ) } - viewModel.addSystemMessage("you took a screenshot") + context.addSystemMessage("you took a screenshot") } func saveIdentityState() { - viewModel.identityManager.forceSave() - _ = viewModel.keychain.verifyIdentityKeyExists() + context.forceSaveIdentity() + context.verifyIdentityKeyExists() } func applicationWillTerminate() { - viewModel.meshService.stopServices() + context.stopMeshServices() saveIdentityState() } func markPrivateMessagesAsRead(from peerID: PeerID) { - viewModel.privateChatManager.markAsRead(from: peerID) - viewModel.synchronizePrivateConversationStore() + context.markChatAsRead(from: peerID) + context.synchronizePrivateConversationStore() if peerID.isGeoDM, - let recipientHex = viewModel.nostrKeyMapping[peerID], - case .location(let channel) = viewModel.activeChannel, - let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { - let messages = viewModel.privateChats[peerID] ?? [] + let recipientHex = context.nostrKeyMapping[peerID], + case .location(let channel) = context.activeChannel, + let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { + let messages = context.privateChats[peerID] ?? [] for message in messages where message.senderPeerID == peerID && !message.isRelay { - guard !viewModel.sentReadReceipts.contains(message.id) else { continue } + guard !context.sentReadReceipts.contains(message.id) else { continue } SecureLogger.debug( "GeoDM: sending READ for mid=\(message.id.prefix(8))โ€ฆ to=\(recipientHex.prefix(8))โ€ฆ", category: .session ) - let nostrTransport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge) - nostrTransport.senderPeerID = viewModel.meshService.myPeerID - nostrTransport.sendReadReceiptGeohash( + context.sendGeohashReadReceipt( message.id, toRecipientHex: recipientHex, from: identity ) - viewModel.sentReadReceipts.insert(message.id) + context.markReadReceiptSent(message.id) } return } @@ -104,13 +195,13 @@ final class ChatLifecycleCoordinator { let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { noiseKeyHex = peerID peerNostrPubkey = favoriteStatus.peerNostrPublicKey - } else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + } else if let peer = context.unifiedPeer(for: peerID) { noiseKeyHex = PeerID(hexData: peer.noisePublicKey) let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey) peerNostrPubkey = favoriteStatus?.peerNostrPublicKey - if let noiseKeyHex, viewModel.unreadPrivateMessages.contains(noiseKeyHex) { - viewModel.unreadPrivateMessages.remove(noiseKeyHex) + if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) { + context.unreadPrivateMessages.remove(noiseKeyHex) } } @@ -121,37 +212,37 @@ final class ChatLifecycleCoordinator { continue } - guard !viewModel.sentReadReceipts.contains(message.id) else { continue } + guard !context.sentReadReceipts.contains(message.id) else { continue } let receipt = ReadReceipt( originalMessageID: message.id, - readerID: viewModel.meshService.myPeerID, - readerNickname: viewModel.nickname + readerID: context.myPeerID, + readerNickname: context.nickname ) let recipientPeerID = peerID.isHex ? peerID - : (viewModel.unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID) + : (context.unifiedPeer(for: peerID)?.peerID ?? peerID) - viewModel.messageRouter.sendReadReceipt(receipt, to: recipientPeerID) - viewModel.sentReadReceipts.insert(message.id) + context.routeReadReceipt(receipt, to: recipientPeerID) + context.markReadReceiptSent(message.id) } } func getMessages(for peerID: PeerID?) -> [BitchatMessage] { - guard let peerID else { return viewModel.messages } + guard let peerID else { return context.messages } return getPrivateChatMessages(for: peerID) } func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { var combined: [BitchatMessage] = [] - if let ephemeralMessages = viewModel.privateChats[peerID] { + if let ephemeralMessages = context.privateChats[peerID] { combined.append(contentsOf: ephemeralMessages) } - if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + if let peer = context.unifiedPeer(for: peerID) { let noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - if noiseKeyHex != peerID, let stableMessages = viewModel.privateChats[noiseKeyHex] { + if noiseKeyHex != peerID, let stableMessages = context.privateChats[noiseKeyHex] { combined.append(contentsOf: stableMessages) } } @@ -175,12 +266,12 @@ final class ChatLifecycleCoordinator { private extension ChatLifecycleCoordinator { func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) { - guard let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) else { return } + guard let peerNickname = context.peerNickname(for: peerID) else { return } - let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID) + let sessionState = context.noiseSessionState(for: peerID) switch sessionState { case .established: - viewModel.messageRouter.sendPrivate( + context.routePrivateMessage( message, to: peerID, recipientNickname: peerNickname, @@ -203,30 +294,30 @@ private extension ChatLifecycleCoordinator { isRelay: false, originalSender: nil, isPrivate: true, - recipientNickname: viewModel.meshService.peerNickname(peerID: peerID), - senderPeerID: viewModel.meshService.myPeerID + recipientNickname: context.peerNickname(for: peerID), + senderPeerID: context.myPeerID ) - var chats = viewModel.privateChats + var chats = context.privateChats if chats[peerID] == nil { chats[peerID] = [] } chats[peerID]?.append(notice) - viewModel.privateChats = chats + context.privateChats = chats } func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) { - Task { @MainActor [weak viewModel] in - guard let viewModel else { return } + Task { @MainActor [weak context = self.context] in + guard let context else { return } do { - let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) + let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) let event = try NostrProtocol.createEphemeralGeohashEvent( content: message, geohash: channel.geohash, senderIdentity: identity, - nickname: viewModel.nickname, - teleported: viewModel.locationManager.teleported + nickname: context.nickname, + teleported: context.isTeleported ) let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) @@ -236,10 +327,10 @@ private extension ChatLifecycleCoordinator { NostrRelayManager.shared.sendEvent(event, to: targetRelays) } - viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) } catch { SecureLogger.error("โŒ Failed to send geohash screenshot message: \(error)", category: .session) - viewModel.addSystemMessage( + context.addSystemMessage( String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") ) } diff --git a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift index 2ddd574f..fbabf1fb 100644 --- a/bitchat/ViewModels/ChatMediaTransferCoordinator.swift +++ b/bitchat/ViewModels/ChatMediaTransferCoordinator.swift @@ -6,26 +6,90 @@ import Foundation import UIKit #endif +/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatMediaTransferCoordinatorContextTests`) and makes its +/// true dependencies explicit. +@MainActor +protocol ChatMediaTransferContext: AnyObject { + // MARK: Composition state + var canSendMediaInCurrentContext: Bool { get } + var selectedPrivateChatPeer: PeerID? { get } + var nickname: String { get } + var myPeerID: PeerID { get } + var activeChannel: ChannelID { get } + func nicknameForPeer(_ peerID: PeerID) -> String + func currentPublicSender() -> (name: String, peerID: PeerID) + + // MARK: Message state + var privateChats: [PeerID: [BitchatMessage]] { get set } + func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) + func refreshVisibleMessages(from channel: ChannelID?) + func trimMessagesIfNeeded() + func removeMessage(withID messageID: String, cleanupFile: Bool) + func addSystemMessage(_ content: String) + /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). + func notifyUIChanged() + + // MARK: Delivery status & dedup + func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) + func normalizedContentKey(_ content: String) -> String + func recordContentKey(_ key: String, timestamp: Date) + + // MARK: Mesh file transfer + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) + func cancelTransfer(_ transferId: String) +} + +extension ChatViewModel: ChatMediaTransferContext { + // `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`, + // `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`, + // `currentPublicSender()`, `privateChats`, + // `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`, + // `trimMessagesIfNeeded()`, `removeMessage(withID:cleanupFile:)`, + // `addSystemMessage(_:)`, `notifyUIChanged()`, + // `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`, + // and `recordContentKey(_:timestamp:)` are shared requirements with the + // other contexts or satisfied by existing `ChatViewModel` members. The + // members below flatten mesh service accesses. + + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { + meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) + } + + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { + meshService.sendFileBroadcast(packet, transferId: transferId) + } + + func cancelTransfer(_ transferId: String) { + meshService.cancelTransfer(transferId) + } +} + @MainActor final class ChatMediaTransferCoordinator { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatMediaTransferContext private(set) var transferIdToMessageIDs: [String: [String]] = [:] private(set) var messageIDToTransferId: [String: String] = [:] - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatMediaTransferContext) { + self.context = context } func sendVoiceNote(at url: URL) { - guard viewModel.canSendMediaInCurrentContext else { + guard context.canSendMediaInCurrentContext else { SecureLogger.info("Voice note blocked outside mesh/private context", category: .session) try? FileManager.default.removeItem(at: url) - viewModel.addSystemMessage("Voice notes are only available in mesh chats.") + context.addSystemMessage("Voice notes are only available in mesh chats.") return } - let targetPeer = viewModel.selectedPrivateChatPeer + let targetPeer = context.selectedPrivateChatPeer let message = enqueueMediaMessage( content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer @@ -41,9 +105,9 @@ final class ChatMediaTransferCoordinator { guard let self else { return } self.registerTransfer(transferId: transferId, messageID: messageID) if let peerID = targetPeer { - self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) + self.context.sendFilePrivate(packet, to: peerID, transferId: transferId) } else { - self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId) + self.context.sendFileBroadcast(packet, transferId: transferId) } } } catch ChatMediaPreparationError.voiceNoteTooLarge(let size) { @@ -96,20 +160,20 @@ final class ChatMediaTransferCoordinator { #endif func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) { - guard viewModel.canSendMediaInCurrentContext else { + guard context.canSendMediaInCurrentContext else { SecureLogger.info("Image send blocked outside mesh/private context", category: .session) cleanup?() - viewModel.addSystemMessage("Images are only available in mesh chats.") + context.addSystemMessage("Images are only available in mesh chats.") return } - let targetPeer = viewModel.selectedPrivateChatPeer + let targetPeer = context.selectedPrivateChatPeer do { try ImageUtils.validateImageSource(at: sourceURL) } catch { SecureLogger.error("Image send preparation failed: \(error)", category: .session) - viewModel.addSystemMessage("Failed to prepare image for sending.") + context.addSystemMessage("Failed to prepare image for sending.") return } @@ -127,22 +191,22 @@ final class ChatMediaTransferCoordinator { let transferId = self.makeTransferID(messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID) if let peerID = targetPeer { - self.viewModel.meshService.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId) + self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId) } else { - self.viewModel.meshService.sendFileBroadcast(prepared.packet, transferId: transferId) + self.context.sendFileBroadcast(prepared.packet, transferId: transferId) } } } catch ChatMediaPreparationError.imageTooLarge(let size) { SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session) await MainActor.run { [weak self] in guard let self else { return } - self.viewModel.addSystemMessage("Image is too large to send.") + self.context.addSystemMessage("Image is too large to send.") } } catch { SecureLogger.error("Image send preparation failed: \(error)", category: .session) await MainActor.run { [weak self] in guard let self else { return } - self.viewModel.addSystemMessage("Failed to prepare image for sending.") + self.context.addSystemMessage("Failed to prepare image for sending.") } } } @@ -154,22 +218,22 @@ final class ChatMediaTransferCoordinator { if let peerID = targetPeer { message = BitchatMessage( - sender: viewModel.nickname, + sender: context.nickname, content: content, timestamp: timestamp, isRelay: false, originalSender: nil, isPrivate: true, - recipientNickname: viewModel.nicknameForPeer(peerID), - senderPeerID: viewModel.meshService.myPeerID, + recipientNickname: context.nicknameForPeer(peerID), + senderPeerID: context.myPeerID, deliveryStatus: .sending ) - var chats = viewModel.privateChats + var chats = context.privateChats chats[peerID, default: []].append(message) - viewModel.privateChats = chats - viewModel.trimMessagesIfNeeded() + context.privateChats = chats + context.trimMessagesIfNeeded() } else { - let (displayName, senderPeerID) = viewModel.currentPublicSender() + let (displayName, senderPeerID) = context.currentPublicSender() message = BitchatMessage( sender: displayName, content: content, @@ -181,14 +245,14 @@ final class ChatMediaTransferCoordinator { senderPeerID: senderPeerID, deliveryStatus: .sending ) - viewModel.timelineStore.append(message, to: viewModel.activeChannel) - viewModel.refreshVisibleMessages(from: viewModel.activeChannel) - viewModel.trimMessagesIfNeeded() + context.appendTimelineMessage(message, to: context.activeChannel) + context.refreshVisibleMessages(from: context.activeChannel) + context.trimMessagesIfNeeded() } - let key = viewModel.deduplicationService.normalizedContentKey(message.content) - viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp) - viewModel.objectWillChange.send() + let key = context.normalizedContentKey(message.content) + context.recordContentKey(key, timestamp: timestamp) + context.notifyUIChanged() return message } @@ -217,7 +281,7 @@ final class ChatMediaTransferCoordinator { } func handleMediaSendFailure(messageID: String, reason: String) { - viewModel.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) + context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason)) clearTransferMapping(for: messageID) } @@ -225,18 +289,18 @@ final class ChatMediaTransferCoordinator { switch event { case .started(let id, let total): guard let messageID = transferIdToMessageIDs[id]?.first else { return } - viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) + context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total)) case .updated(let id, let sent, let total): guard let messageID = transferIdToMessageIDs[id]?.first else { return } - viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) + context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total)) case .completed(let id, _): guard let messageID = transferIdToMessageIDs[id]?.first else { return } - viewModel.updateMessageDeliveryStatus(messageID, status: .sent) + context.updateMessageDeliveryStatus(messageID, status: .sent) clearTransferMapping(for: messageID) case .cancelled(let id, _, _): guard let messageID = transferIdToMessageIDs[id]?.first else { return } clearTransferMapping(for: messageID) - viewModel.removeMessage(withID: messageID, cleanupFile: true) + context.removeMessage(withID: messageID, cleanupFile: true) } } @@ -270,15 +334,15 @@ final class ChatMediaTransferCoordinator { if let transferId = messageIDToTransferId[messageID], let active = transferIdToMessageIDs[transferId]?.first, active == messageID { - viewModel.meshService.cancelTransfer(transferId) + context.cancelTransfer(transferId) } clearTransferMapping(for: messageID) - viewModel.removeMessage(withID: messageID, cleanupFile: true) + context.removeMessage(withID: messageID, cleanupFile: true) } func deleteMediaMessage(messageID: String) { clearTransferMapping(for: messageID) - viewModel.removeMessage(withID: messageID, cleanupFile: true) + context.removeMessage(withID: messageID, cleanupFile: true) } } diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 372c7956..320f7caf 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -3,24 +3,206 @@ import BitLogger import CoreBluetooth import Foundation -final class ChatPeerIdentityCoordinator { - private unowned let viewModel: ChatViewModel +/// The narrow surface `ChatPeerIdentityCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatPeerIdentityCoordinatorContextTests`) and makes its true +/// dependencies explicit. Several members are flattened service accesses โ€” +/// this coordinator implements the `ChatViewModel`-level peer-identity API, so +/// its context members deliberately sit one level below those wrappers +/// (`unifiedIsBlocked(_:)` vs `isPeerBlocked(_:)`, `unifiedFingerprint(for:)` +/// vs `getFingerprint(for:)`, โ€ฆ) to avoid call cycles. +@MainActor +protocol ChatPeerIdentityContext: AnyObject { + // MARK: Conversation state + var privateChats: [PeerID: [BitchatMessage]] { get set } + var unreadPrivateMessages: Set { get set } + var selectedPrivateChatPeer: PeerID? { get set } + var selectedPrivateChatFingerprint: String? { get set } + var nickname: String { get } + var myPeerID: PeerID { get } + var activeChannel: ChannelID { get } + /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). + func notifyUIChanged() + func addSystemMessage(_ content: String) - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + // MARK: Private chat session lifecycle + /// Merges messages stored under alternate peer-ID representations into `peerID`'s chat. + /// Returns `true` when unread messages were discovered during consolidation. + @discardableResult + func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool + /// Marks read receipts as sent for own messages already delivered/read in + /// `peerID`'s chat. (Single mutation path into the owner's + /// `sentReadReceipts`; this coordinator never touches the raw set.) + func syncReadReceiptsForSentMessages(for peerID: PeerID) + /// Re-targets the private chat session in the chat manager (no store-sync side effects). + func beginPrivateChatSession(with peerID: PeerID) + func synchronizePrivateConversationStore() + func synchronizeConversationSelectionStore() + func markPrivateMessagesAsRead(from peerID: PeerID) + + // MARK: Unified peer service + var connectedPeers: Set { get } + /// The peer's current entry in the unified peer service, if known. + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? + func unifiedIsBlocked(_ peerID: PeerID) -> Bool + func unifiedToggleFavorite(_ peerID: PeerID) + func unifiedFingerprint(for peerID: PeerID) -> String? + func unifiedPeerID(forNickname nickname: String) -> PeerID? + /// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected. + func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? + + // MARK: Mesh & Noise sessions + func peerNickname(for peerID: PeerID) -> String? + func meshPeerNicknames() -> [PeerID: String] + func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState + func triggerHandshake(with peerID: PeerID) + func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool + func hasNoiseSession(with peerID: PeerID) -> Bool + /// Our own Noise identity fingerprint. + func noiseIdentityFingerprint() -> String + + // MARK: Identity store (fingerprints & encryption status) + func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) + /// Moves the stored fingerprint mapping from `oldPeerID` to `newPeerID`, + /// falling back to `fallback` when none was stored. Returns the migrated fingerprint. + func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? + func isVerifiedFingerprint(_ fingerprint: String) -> Bool + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) + func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? + func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) + func invalidateStoredEncryptionCache(for peerID: PeerID?) + func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? + + // MARK: Geohash & Nostr + var geoNicknames: [String: String] { get } + func visibleGeohashPeople() -> [GeoPerson] + /// Records the Nostr pubkey behind a (possibly virtual) peer ID. + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) + func bridgedNostrPublicKey(for noiseKey: Data) -> String? + func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) +} + +extension ChatViewModel: ChatPeerIdentityContext { + // `privateChats`, `unreadPrivateMessages`, `selectedPrivateChatPeer`, + // `selectedPrivateChatFingerprint`, `nickname`, `myPeerID`, + // `activeChannel`, `connectedPeers`, `geoNicknames`, `notifyUIChanged()`, + // `addSystemMessage(_:)`, `peerNickname(for:)`, `meshPeerNicknames()`, + // `ephemeralPeerID(forNoiseKey:)`, `unifiedPeer(for:)`, + // `registerNostrKeyMapping(_:for:)`, `visibleGeohashPeople()`, + // `markPrivateMessagesAsRead(from:)`, `sendFavoriteNotificationViaNostr`, + // and the conversation-store sync methods are shared requirements with + // the other contexts or satisfied by existing `ChatViewModel` members. + // The single-writer intent op `syncReadReceiptsForSentMessages(for:)` + // lives next to its backing state in `ChatViewModel`. The members below + // flatten nested service accesses into intent-named calls. + + @discardableResult + func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool { + privateChatManager.consolidateMessages( + for: peerID, + peerNickname: peerNickname, + persistedReadReceipts: sentReadReceipts + ) + } + + func beginPrivateChatSession(with peerID: PeerID) { + privateChatManager.startChat(with: peerID) + } + + func unifiedIsBlocked(_ peerID: PeerID) -> Bool { + unifiedPeerService.isBlocked(peerID) + } + + func unifiedToggleFavorite(_ peerID: PeerID) { + unifiedPeerService.toggleFavorite(peerID) + } + + func unifiedFingerprint(for peerID: PeerID) -> String? { + unifiedPeerService.getFingerprint(for: peerID) + } + + func unifiedPeerID(forNickname nickname: String) -> PeerID? { + unifiedPeerService.getPeerID(for: nickname) + } + + func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState { + meshService.getNoiseSessionState(for: peerID) + } + + func triggerHandshake(with peerID: PeerID) { + meshService.triggerHandshake(with: peerID) + } + + func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { + meshService.getNoiseService().hasEstablishedSession(with: peerID) + } + + func hasNoiseSession(with peerID: PeerID) -> Bool { + meshService.getNoiseService().hasSession(with: peerID) + } + + func noiseIdentityFingerprint() -> String { + meshService.getNoiseService().getIdentityFingerprint() + } + + func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) { + peerIdentityStore.setFingerprint(fingerprint, for: peerID) + } + + func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? { + peerIdentityStore.migrateFingerprintMapping(from: oldPeerID, to: newPeerID, fallback: fallback) + } + + func isVerifiedFingerprint(_ fingerprint: String) -> Bool { + peerIdentityStore.isVerified(fingerprint) + } + + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { + peerIdentityStore.setEncryptionStatus(status, for: peerID) + } + + func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? { + peerIdentityStore.cachedEncryptionStatus(for: peerID) + } + + func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) { + peerIdentityStore.setCachedEncryptionStatus(status, for: peerID) + } + + func invalidateStoredEncryptionCache(for peerID: PeerID?) { + peerIdentityStore.invalidateEncryptionCache(for: peerID) + } + + func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? { + identityManager.getSocialIdentity(for: fingerprint) + } + + func bridgedNostrPublicKey(for noiseKey: Data) -> String? { + idBridge.getNostrPublicKey(for: noiseKey) + } +} + +final class ChatPeerIdentityCoordinator { + private unowned let context: any ChatPeerIdentityContext + + init(context: any ChatPeerIdentityContext) { + self.context = context } @MainActor func openMostRelevantPrivateChat() { - let unreadSorted = viewModel.unreadPrivateMessages - .map { ($0, viewModel.privateChats[$0]?.last?.timestamp ?? Date.distantPast) } + let unreadSorted = context.unreadPrivateMessages + .map { ($0, context.privateChats[$0]?.last?.timestamp ?? Date.distantPast) } .sorted { $0.1 > $1.1 } if let target = unreadSorted.first?.0 { startPrivateChat(with: target) return } - let recent = viewModel.privateChats + let recent = context.privateChats .map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) } .sorted { $0.ts > $1.ts } if let target = recent.first?.id { @@ -30,7 +212,7 @@ final class ChatPeerIdentityCoordinator { @MainActor func isPeerBlocked(_ peerID: PeerID) -> Bool { - viewModel.unifiedPeerService.isBlocked(peerID) + context.unifiedIsBlocked(peerID) } @MainActor @@ -38,24 +220,24 @@ final class ChatPeerIdentityCoordinator { var noiseKeyPeerID: PeerID? var nostrPeerID: PeerID? - if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + if let peer = context.unifiedPeer(for: peerID) { noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey) if let nostrHex = peer.nostrPublicKey { nostrPeerID = PeerID(nostr_: nostrHex) } } - let context = ChatUnreadPeerContext( + let unreadContext = ChatUnreadPeerContext( peerID: peerID, noiseKeyPeerID: noiseKeyPeerID, nostrPeerID: nostrPeerID, - nickname: viewModel.meshService.peerNickname(peerID: peerID) + nickname: context.peerNickname(for: peerID) ) return ChatUnreadStateResolver.hasUnreadMessages( - for: context, - unreadPrivateMessages: viewModel.unreadPrivateMessages, - privateChats: viewModel.privateChats + for: unreadContext, + unreadPrivateMessages: context.unreadPrivateMessages, + privateChats: context.privateChats ) } @@ -66,8 +248,8 @@ final class ChatPeerIdentityCoordinator { return } - viewModel.unifiedPeerService.toggleFavorite(peerID) - viewModel.objectWillChange.send() + context.unifiedToggleFavorite(peerID) + context.notifyUIChanged() } @MainActor @@ -76,36 +258,36 @@ final class ChatPeerIdentityCoordinator { return FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)?.isFavorite ?? false } - return viewModel.unifiedPeerService.getPeer(by: peerID)?.isFavorite ?? false + return context.unifiedPeer(for: peerID)?.isFavorite ?? false } @MainActor func updatePrivateChatPeerIfNeeded() { - guard let chatFingerprint = viewModel.selectedPrivateChatFingerprint, + guard let chatFingerprint = context.selectedPrivateChatFingerprint, let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else { return } - if let oldPeerID = viewModel.selectedPrivateChatPeer, oldPeerID != currentPeerID { + if let oldPeerID = context.selectedPrivateChatPeer, oldPeerID != currentPeerID { migrateChatState(from: oldPeerID, to: currentPeerID) - viewModel.selectedPrivateChatPeer = currentPeerID - } else if viewModel.selectedPrivateChatPeer == nil { - viewModel.selectedPrivateChatPeer = currentPeerID + context.selectedPrivateChatPeer = currentPeerID + } else if context.selectedPrivateChatPeer == nil { + context.selectedPrivateChatPeer = currentPeerID } - var unread = viewModel.unreadPrivateMessages + var unread = context.unreadPrivateMessages unread.remove(currentPeerID) - viewModel.unreadPrivateMessages = unread + context.unreadPrivateMessages = unread } @MainActor func startPrivateChat(with peerID: PeerID) { - guard peerID != viewModel.meshService.myPeerID else { return } + guard peerID != context.myPeerID else { return } - let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "unknown" + let peerNickname = context.peerNickname(for: peerID) ?? "unknown" - if viewModel.unifiedPeerService.isBlocked(peerID) { - viewModel.addSystemMessage( + if context.unifiedIsBlocked(peerID) { + context.addSystemMessage( String( format: String( localized: "system.chat.blocked", @@ -118,9 +300,9 @@ final class ChatPeerIdentityCoordinator { return } - if let peer = viewModel.unifiedPeerService.getPeer(by: peerID), + if let peer = context.unifiedPeer(for: peerID), peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected { - viewModel.addSystemMessage( + context.addSystemMessage( String( format: String( localized: "system.chat.requires_favorite", @@ -133,16 +315,12 @@ final class ChatPeerIdentityCoordinator { return } - _ = viewModel.privateChatManager.consolidateMessages( - for: peerID, - peerNickname: peerNickname, - persistedReadReceipts: viewModel.sentReadReceipts - ) + _ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname) if !peerID.isGeoDM && !peerID.isGeoChat { - switch viewModel.meshService.getNoiseSessionState(for: peerID) { + switch context.noiseSessionState(for: peerID) { case .none, .failed: - viewModel.meshService.triggerHandshake(with: peerID) + context.triggerHandshake(with: peerID) case .handshakeQueued, .handshaking, .established: break } @@ -150,28 +328,24 @@ final class ChatPeerIdentityCoordinator { SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session) } - viewModel.privateChatManager.syncReadReceiptsForSentMessages( - peerID: peerID, - nickname: viewModel.nickname, - externalReceipts: &viewModel.sentReadReceipts - ) + context.syncReadReceiptsForSentMessages(for: peerID) if let fingerprint = getFingerprint(for: peerID) { - viewModel.peerIdentityStore.setFingerprint(fingerprint, for: peerID) - viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint) + context.setStoredFingerprint(fingerprint, for: peerID) + context.selectedPrivateChatFingerprint = fingerprint } else { - viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil) + context.selectedPrivateChatFingerprint = nil } - viewModel.privateChatManager.startChat(with: peerID) - viewModel.synchronizePrivateConversationStore() - viewModel.synchronizeConversationSelectionStore() - viewModel.markPrivateMessagesAsRead(from: peerID) + context.beginPrivateChatSession(with: peerID) + context.synchronizePrivateConversationStore() + context.synchronizeConversationSelectionStore() + context.markPrivateMessagesAsRead(from: peerID) } @MainActor func endPrivateChat() { - viewModel.selectedPrivateChatPeer = nil - viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil) + context.selectedPrivateChatPeer = nil + context.selectedPrivateChatFingerprint = nil } @MainActor @@ -182,8 +356,8 @@ final class ChatPeerIdentityCoordinator { func handleFavoriteStatusChanged(_ notification: Notification) { guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return } - Task { @MainActor [weak viewModel] in - guard let viewModel else { return } + Task { @MainActor [weak context = self.context] in + guard let context else { return } if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool, isKeyUpdate, @@ -200,28 +374,26 @@ final class ChatPeerIdentityCoordinator { let peerID = PeerID(hexData: peerPublicKey) let action = isFavorite ? "favorited" : "unfavorited" let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey) - viewModel.addSystemMessage("\(peerNickname) \(action) you") + context.addSystemMessage("\(peerNickname) \(action) you") } } } @MainActor func updateEncryptionStatusForPeers() { - for peerID in viewModel.connectedPeers { + for peerID in context.connectedPeers { updateEncryptionStatus(for: peerID) } } @MainActor func updateEncryptionStatus(for peerID: PeerID) { - let noiseService = viewModel.meshService.getNoiseService() - - if noiseService.hasEstablishedSession(with: peerID) { - viewModel.peerIdentityStore.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID) - } else if noiseService.hasSession(with: peerID) { - viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID) + if context.hasEstablishedNoiseSession(with: peerID) { + context.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID) + } else if context.hasNoiseSession(with: peerID) { + context.setEncryptionStatus(.noiseHandshaking, for: peerID) } else { - viewModel.peerIdentityStore.setEncryptionStatus(nil, for: peerID) + context.setEncryptionStatus(nil, for: peerID) } invalidateEncryptionCache(for: peerID) @@ -229,12 +401,12 @@ final class ChatPeerIdentityCoordinator { @MainActor func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { - if let cachedStatus = viewModel.peerIdentityStore.cachedEncryptionStatus(for: peerID) { + if let cachedStatus = context.cachedEncryptionStatus(for: peerID) { return cachedStatus } let hasEverEstablishedSession = getFingerprint(for: peerID) != nil - let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID) + let sessionState = context.noiseSessionState(for: peerID) let status: EncryptionStatus switch sessionState { @@ -248,18 +420,18 @@ final class ChatPeerIdentityCoordinator { status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none } - viewModel.peerIdentityStore.setCachedEncryptionStatus(status, for: peerID) + context.setCachedEncryptionStatus(status, for: peerID) return status } @MainActor func invalidateEncryptionCache(for peerID: PeerID? = nil) { - viewModel.peerIdentityStore.invalidateEncryptionCache(for: peerID) + context.invalidateStoredEncryptionCache(for: peerID) } @MainActor func getFingerprint(for peerID: PeerID) -> String? { - viewModel.unifiedPeerService.getFingerprint(for: peerID) + context.unifiedFingerprint(for: peerID) } @MainActor @@ -270,12 +442,12 @@ final class ChatPeerIdentityCoordinator { return peerID.id } - if let nickname = viewModel.meshService.getPeerNicknames()[peerID] { + if let nickname = context.meshPeerNicknames()[peerID] { return nickname } if let fingerprint = getFingerprint(for: peerID), - let identity = viewModel.identityManager.getSocialIdentity(for: fingerprint) { + let identity = context.socialIdentity(forFingerprint: fingerprint) { if let petname = identity.localPetname { return petname } @@ -289,19 +461,18 @@ final class ChatPeerIdentityCoordinator { @MainActor func getMyFingerprint() -> String { - viewModel.meshService.getNoiseService().getIdentityFingerprint() + context.noiseIdentityFingerprint() } @MainActor func getPeerIDForNickname(_ nickname: String) -> PeerID? { - switch viewModel.activeChannel { + switch context.activeChannel { case .location: if nickname.contains("#"), - let person = viewModel.publicConversationCoordinator - .visibleGeohashPeople() + let person = context.visibleGeohashPeople() .first(where: { $0.displayName == nickname }) { let conversationKey = PeerID(nostr_: person.id) - viewModel.registerNostrKeyMapping(person.id, for: conversationKey) + context.registerNostrKeyMapping(person.id, for: conversationKey) return conversationKey } @@ -310,9 +481,9 @@ final class ChatPeerIdentityCoordinator { .first .map(String.init)? .lowercased() ?? nickname.lowercased() - if let pubkey = viewModel.geoNicknames.first(where: { $0.value.lowercased() == base })?.key { + if let pubkey = context.geoNicknames.first(where: { $0.value.lowercased() == base })?.key { let conversationKey = PeerID(nostr_: pubkey) - viewModel.registerNostrKeyMapping(pubkey, for: conversationKey) + context.registerNostrKeyMapping(pubkey, for: conversationKey) return conversationKey } @@ -320,12 +491,12 @@ final class ChatPeerIdentityCoordinator { break } - return viewModel.unifiedPeerService.getPeerID(for: nickname) + return context.unifiedPeerID(forNickname: nickname) } @MainActor func nicknameForPeer(_ peerID: PeerID) -> String { - if let name = viewModel.meshService.peerNickname(peerID: peerID) { + if let name = context.peerNickname(for: peerID) { return name } if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), @@ -344,7 +515,7 @@ final class ChatPeerIdentityCoordinator { private extension ChatPeerIdentityCoordinator { @MainActor func currentPeerID(forFingerprint fingerprint: String) -> PeerID? { - for peerID in viewModel.connectedPeers where getFingerprint(for: peerID) == fingerprint { + for peerID in context.connectedPeers where getFingerprint(for: peerID) == fingerprint { return peerID } return nil @@ -352,8 +523,8 @@ private extension ChatPeerIdentityCoordinator { @MainActor func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) { - if let oldMessages = viewModel.privateChats[oldPeerID] { - var chats = viewModel.privateChats + if let oldMessages = context.privateChats[oldPeerID] { + var chats = context.privateChats chats[newPeerID, default: []].append(contentsOf: oldMessages) chats[newPeerID]?.sort { $0.timestamp < $1.timestamp } @@ -367,45 +538,45 @@ private extension ChatPeerIdentityCoordinator { } chats.removeValue(forKey: oldPeerID) - viewModel.privateChats = chats + context.privateChats = chats } - var unread = viewModel.unreadPrivateMessages + var unread = context.unreadPrivateMessages if unread.contains(oldPeerID) { unread.remove(oldPeerID) unread.insert(newPeerID) - viewModel.unreadPrivateMessages = unread + context.unreadPrivateMessages = unread } } @MainActor func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) { - if viewModel.selectedPrivateChatPeer == oldPeerID { + if context.selectedPrivateChatPeer == oldPeerID { SecureLogger.info("๐Ÿ“ฑ Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session) - } else if viewModel.privateChats[oldPeerID] != nil { + } else if context.privateChats[oldPeerID] != nil { SecureLogger.debug("๐Ÿ“ฑ Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session) } migrateChatState(from: oldPeerID, to: newPeerID) - if viewModel.selectedPrivateChatPeer == oldPeerID { - viewModel.selectedPrivateChatPeer = newPeerID + if context.selectedPrivateChatPeer == oldPeerID { + context.selectedPrivateChatPeer = newPeerID } - if let fingerprint = viewModel.peerIdentityStore.migrateFingerprintMapping( + if let fingerprint = context.migrateFingerprintMapping( from: oldPeerID, to: newPeerID, fallback: getFingerprint(for: newPeerID) ) { - if viewModel.selectedPrivateChatPeer == newPeerID { - viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint) + if context.selectedPrivateChatPeer == newPeerID { + context.selectedPrivateChatFingerprint = fingerprint } } } @MainActor func favoriteNotificationNickname(for peerID: PeerID, peerPublicKey: Data) -> String { - if let nickname = viewModel.meshService.peerNickname(peerID: peerID) { + if let nickname = context.peerNickname(for: peerID) { return nickname } if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) { @@ -417,7 +588,7 @@ private extension ChatPeerIdentityCoordinator { @MainActor func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus { if let fingerprint = getFingerprint(for: peerID), - viewModel.peerIdentityStore.isVerified(fingerprint) { + context.isVerifiedFingerprint(fingerprint) { return .noiseVerified } return .noiseSecured @@ -425,18 +596,18 @@ private extension ChatPeerIdentityCoordinator { @MainActor func toggleFavoriteForNoiseKey(_ noisePublicKey: Data, peerID: PeerID) { - if let ephemeralID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == noisePublicKey })?.peerID { - viewModel.unifiedPeerService.toggleFavorite(ephemeralID) - viewModel.objectWillChange.send() + if let ephemeralID = context.ephemeralPeerID(forNoiseKey: noisePublicKey) { + context.unifiedToggleFavorite(ephemeralID) + context.notifyUIChanged() return } let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) - let fallbackNickname = viewModel.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender + let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender let plan = ChatFavoriteTogglePolicy.plan( currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init), fallbackNickname: fallbackNickname, - bridgedNostrKey: viewModel.idBridge.getNostrPublicKey(for: noisePublicKey) + bridgedNostrKey: context.bridgedNostrPublicKey(for: noisePublicKey) ) switch plan.persistenceAction { @@ -451,10 +622,10 @@ private extension ChatPeerIdentityCoordinator { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) } - viewModel.objectWillChange.send() + context.notifyUIChanged() if case .send(let isFavorite) = plan.notification { - viewModel.sendFavoriteNotificationViaNostr( + context.sendFavoriteNotificationViaNostr( noisePublicKey: noisePublicKey, isFavorite: isFavorite ) diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift index a671f7b3..ee19a527 100644 --- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift +++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift @@ -2,26 +2,139 @@ import BitFoundation import BitLogger import Foundation -final class ChatTransportEventCoordinator { - private unowned let viewModel: ChatViewModel +/// The narrow surface `ChatTransportEventCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatTransportEventCoordinatorContextTests`) and makes its +/// true dependencies explicit. +@MainActor +protocol ChatTransportEventContext: AnyObject { + // MARK: Connection & chat state + var isConnected: Bool { get set } + var nickname: String { get } + var myPeerID: PeerID { get } + var privateChats: [PeerID: [BitchatMessage]] { get set } + var unreadPrivateMessages: Set { get set } + var selectedPrivateChatPeer: PeerID? { get set } + /// Forgets that read receipts were sent for `ids` so READ acks can be + /// re-sent after the peer reconnects. (Single mutation path for the + /// owner's `sentReadReceipts`; this coordinator never reads the raw set.) + func unmarkReadReceiptsSent(_ ids: [String]) + /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). + func notifyUIChanged() - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + // MARK: Inbound message handling + func isMessageBlocked(_ message: BitchatMessage) -> Bool + func handlePrivateMessage(_ message: BitchatMessage) + func handlePublicMessage(_ message: BitchatMessage) + func checkForMentions(_ message: BitchatMessage) + func sendHapticFeedback(for message: BitchatMessage) + func parseMentions(from content: String) -> [String] + + // MARK: Peer identity & sessions + func isPeerBlocked(_ peerID: PeerID) -> Bool + /// The peer's current entry in the unified peer service, if known. + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? + func resolveNickname(for peerID: PeerID) -> String + func registerEphemeralSession(peerID: PeerID) + func removeEphemeralSession(peerID: PeerID) + /// Resolves the peer's Noise static key from the active Noise session, if any. + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? + func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) + func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? + + // MARK: Routing & acknowledgements + func flushRouterOutbox(for peerID: PeerID) + func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) + + // MARK: Delivery status + /// Applies the status to every known location of the message. + /// Returns `false` when no message with that ID was updated. + @discardableResult + func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool + func deliveryStatus(for messageID: String) -> DeliveryStatus? + + // MARK: Verification payloads + func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) + func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) +} + +extension ChatViewModel: ChatTransportEventContext { + // `isConnected`, `nickname`, `myPeerID`, `privateChats`, + // `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`, + // the inbound message handlers, `isPeerBlocked(_:)`, + // `parseMentions(from:)`, `resolveNickname(for:)`, + // `cacheStablePeerID(_:for:)`, and `cachedStablePeerID(for:)` are shared + // requirements with the other contexts or satisfied by existing + // `ChatViewModel` members. The single-writer intent op + // `unmarkReadReceiptsSent(_:)` lives next to its backing state in + // `ChatViewModel`. The members below flatten nested service accesses into + // intent-named calls. + + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { + unifiedPeerService.getPeer(by: peerID) + } + + func registerEphemeralSession(peerID: PeerID) { + identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) + } + + func removeEphemeralSession(peerID: PeerID) { + identityManager.removeEphemeralSession(peerID: peerID) + } + + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { + meshService.getNoiseService().getPeerPublicKeyData(peerID) + } + + func flushRouterOutbox(for peerID: PeerID) { + messageRouter.flushOutbox(for: peerID) + } + + func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { + meshService.sendDeliveryAck(for: messageID, to: peerID) + } + + @discardableResult + func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { + deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: status) + } + + func deliveryStatus(for messageID: String) -> DeliveryStatus? { + deliveryCoordinator.deliveryStatus(for: messageID) + } + + func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) { + verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload) + } + + func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { + verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) + } +} + +final class ChatTransportEventCoordinator { + private unowned let context: any ChatTransportEventContext + + init(context: any ChatTransportEventContext) { + self.context = context } func didReceiveMessage(_ message: BitchatMessage) { - runOnMain { viewModel in - guard !viewModel.isMessageBlocked(message) else { return } + runOnMain { context in + guard !context.isMessageBlocked(message) else { return } guard !message.content.trimmed.isEmpty || message.isPrivate else { return } if message.isPrivate { - viewModel.handlePrivateMessage(message) + context.handlePrivateMessage(message) } else { - viewModel.handlePublicMessage(message) + context.handlePublicMessage(message) } - viewModel.checkForMentions(message) - viewModel.sendHapticFeedback(for: message) + context.checkForMentions(message) + context.sendHapticFeedback(for: message) } } @@ -32,9 +145,9 @@ final class ChatTransportEventCoordinator { timestamp: Date, messageID: String? ) { - runOnMain { viewModel in + runOnMain { context in let normalized = content.trimmed - let mentions = viewModel.parseMentions(from: normalized) + let mentions = context.parseMentions(from: normalized) let message = BitchatMessage( id: messageID, sender: nickname, @@ -48,9 +161,9 @@ final class ChatTransportEventCoordinator { mentions: mentions.isEmpty ? nil : mentions ) - viewModel.handlePublicMessage(message) - viewModel.checkForMentions(message) - viewModel.sendHapticFeedback(for: message) + context.handlePublicMessage(message) + context.checkForMentions(message) + context.sendHapticFeedback(for: message) } } @@ -60,13 +173,13 @@ final class ChatTransportEventCoordinator { payload: Data, timestamp: Date ) { - runOnMain { [self] viewModel in + runOnMain { [self] context in handleNoisePayload( from: peerID, type: type, payload: payload, timestamp: timestamp, - in: viewModel + in: context ) } } @@ -74,60 +187,61 @@ final class ChatTransportEventCoordinator { func didConnectToPeer(_ peerID: PeerID) { SecureLogger.debug("๐Ÿค Peer connected: \(peerID)", category: .session) - runOnMain { viewModel in - viewModel.isConnected = true - viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) - viewModel.objectWillChange.send() + runOnMain { context in + context.isConnected = true + context.registerEphemeralSession(peerID: peerID) + context.notifyUIChanged() - if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) { + if let peer = context.unifiedPeer(for: peerID) { let stablePeerID = PeerID(hexData: peer.noisePublicKey) - viewModel.cacheStablePeerID(stablePeerID, for: peerID) + context.cacheStablePeerID(stablePeerID, for: peerID) } - viewModel.messageRouter.flushOutbox(for: peerID) + context.flushRouterOutbox(for: peerID) } } func didDisconnectFromPeer(_ peerID: PeerID) { SecureLogger.debug("๐Ÿ‘‹ Peer disconnected: \(peerID)", category: .session) - runOnMain { viewModel in - viewModel.identityManager.removeEphemeralSession(peerID: peerID) + runOnMain { context in + context.removeEphemeralSession(peerID: peerID) - var stablePeerID = viewModel.cachedStablePeerID(for: peerID) + var stablePeerID = context.cachedStablePeerID(for: peerID) if stablePeerID == nil, - let key = viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) { + let key = context.noiseSessionPublicKeyData(for: peerID) { let derivedPeerID = PeerID(hexData: key) - viewModel.cacheStablePeerID(derivedPeerID, for: peerID) + context.cacheStablePeerID(derivedPeerID, for: peerID) stablePeerID = derivedPeerID } - if let currentPeerID = viewModel.selectedPrivateChatPeer, + if let currentPeerID = context.selectedPrivateChatPeer, currentPeerID == peerID, let stablePeerID { self.migrateSelectedConversationIfNeeded( from: peerID, to: stablePeerID, - in: viewModel + in: context ) } - if let messages = viewModel.privateChats[peerID] { - for message in messages where message.senderPeerID == peerID { - viewModel.sentReadReceipts.remove(message.id) - } + if let messages = context.privateChats[peerID] { + let receiptIDs = messages + .filter { $0.senderPeerID == peerID } + .map(\.id) + context.unmarkReadReceiptsSent(receiptIDs) } - viewModel.objectWillChange.send() + context.notifyUIChanged() } } } private extension ChatTransportEventCoordinator { - func runOnMain(_ action: @escaping @MainActor (ChatViewModel) -> Void) { - Task { @MainActor [weak viewModel = self.viewModel] in - guard let viewModel else { return } - action(viewModel) + func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) { + Task { @MainActor [weak context = self.context] in + guard let context else { return } + action(context) } } @@ -135,14 +249,14 @@ private extension ChatTransportEventCoordinator { func migrateSelectedConversationIfNeeded( from shortPeerID: PeerID, to stablePeerID: PeerID, - in viewModel: ChatViewModel + in context: any ChatTransportEventContext ) { - if let messages = viewModel.privateChats[shortPeerID] { - if viewModel.privateChats[stablePeerID] == nil { - viewModel.privateChats[stablePeerID] = [] + if let messages = context.privateChats[shortPeerID] { + if context.privateChats[stablePeerID] == nil { + context.privateChats[stablePeerID] = [] } - let existingIDs = Set(viewModel.privateChats[stablePeerID]?.map(\.id) ?? []) + let existingIDs = Set(context.privateChats[stablePeerID]?.map(\.id) ?? []) for message in messages where !existingIDs.contains(message.id) { let migrated = BitchatMessage( id: message.id, @@ -153,25 +267,25 @@ private extension ChatTransportEventCoordinator { originalSender: message.originalSender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID == viewModel.meshService.myPeerID - ? viewModel.meshService.myPeerID + senderPeerID: message.senderPeerID == context.myPeerID + ? context.myPeerID : stablePeerID, mentions: message.mentions, deliveryStatus: message.deliveryStatus ) - viewModel.privateChats[stablePeerID]?.append(migrated) + context.privateChats[stablePeerID]?.append(migrated) } - viewModel.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp } - viewModel.privateChats.removeValue(forKey: shortPeerID) + context.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp } + context.privateChats.removeValue(forKey: shortPeerID) } - if viewModel.unreadPrivateMessages.contains(shortPeerID) { - viewModel.unreadPrivateMessages.remove(shortPeerID) - viewModel.unreadPrivateMessages.insert(stablePeerID) + if context.unreadPrivateMessages.contains(shortPeerID) { + context.unreadPrivateMessages.remove(shortPeerID) + context.unreadPrivateMessages.insert(stablePeerID) } - viewModel.selectedPrivateChatPeer = stablePeerID + context.selectedPrivateChatPeer = stablePeerID } @MainActor @@ -180,19 +294,19 @@ private extension ChatTransportEventCoordinator { type: NoisePayloadType, payload: Data, timestamp: Date, - in viewModel: ChatViewModel + in context: any ChatTransportEventContext ) { switch type { case .privateMessage: guard let packet = PrivateMessagePacket.decode(from: payload) else { return } - guard !viewModel.isPeerBlocked(peerID) else { + guard !context.isPeerBlocked(peerID) else { SecureLogger.debug("๐Ÿšซ Ignoring Noise payload from blocked peer: \(peerID)", category: .security) return } - let senderName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown" - let mentions = viewModel.parseMentions(from: packet.content) + let senderName = context.unifiedPeer(for: peerID)?.nickname ?? "Unknown" + let mentions = context.parseMentions(from: packet.content) let message = BitchatMessage( id: packet.messageID, sender: senderName, @@ -201,24 +315,24 @@ private extension ChatTransportEventCoordinator { isRelay: false, originalSender: nil, isPrivate: true, - recipientNickname: viewModel.nickname, + recipientNickname: context.nickname, senderPeerID: peerID, mentions: mentions.isEmpty ? nil : mentions ) - viewModel.handlePrivateMessage(message) - viewModel.meshService.sendDeliveryAck(for: packet.messageID, to: peerID) + context.handlePrivateMessage(message) + context.sendMeshDeliveryAck(for: packet.messageID, to: peerID) case .delivered: guard let messageID = String(data: payload, encoding: .utf8) else { return } - let name = deliveryStatusName(for: peerID, in: viewModel) - let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus( + let name = deliveryStatusName(for: peerID, in: context) + let didUpdate = context.applyMessageDeliveryStatus( messageID, status: .delivered(to: name, at: Date()) ) if !didUpdate { - if case .read? = viewModel.deliveryCoordinator.deliveryStatus(for: messageID) { + if case .read? = context.deliveryStatus(for: messageID) { SecureLogger.debug("๐Ÿ“ฌ Ignored stale delivered ACK for already-read message id=\(messageID.prefix(8))โ€ฆ from \(peerID.id.prefix(8))โ€ฆ", category: .session) } else { SecureLogger.debug("๐Ÿ“ฌ Delivered ACK for unknown message id=\(messageID.prefix(8))โ€ฆ from \(peerID.id.prefix(8))โ€ฆ", category: .session) @@ -228,8 +342,8 @@ private extension ChatTransportEventCoordinator { case .readReceipt: guard let messageID = String(data: payload, encoding: .utf8) else { return } - let name = deliveryStatusName(for: peerID, in: viewModel) - let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus( + let name = deliveryStatusName(for: peerID, in: context) + let didUpdate = context.applyMessageDeliveryStatus( messageID, status: .read(by: name, at: Date()) ) @@ -239,15 +353,15 @@ private extension ChatTransportEventCoordinator { } case .verifyChallenge: - viewModel.verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload) + context.handleVerifyChallengePayload(from: peerID, payload: payload) case .verifyResponse: - viewModel.verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload) + context.handleVerifyResponsePayload(from: peerID, payload: payload) } } @MainActor - func deliveryStatusName(for peerID: PeerID, in viewModel: ChatViewModel) -> String { - viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? viewModel.resolveNickname(for: peerID) + func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String { + context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID) } } diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index 8ef7f3d1..b61aec2f 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -3,6 +3,115 @@ import BitLogger import Foundation import Security +/// The narrow surface `ChatVerificationCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatVerificationCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatVerificationContext: AnyObject { + // MARK: Fingerprints & verification state + func getFingerprint(for peerID: PeerID) -> String? + /// The UI-facing verified-fingerprint set (peer identity store backed). + var verifiedFingerprints: Set { get set } + /// The persisted verified-fingerprint set from the identity manager. + func persistedVerifiedFingerprints() -> Set + /// Persists the verified flag in the identity manager. + func setIdentityVerified(fingerprint: String, verified: Bool) + /// Updates the UI-facing verified flag in the peer identity store. + func setStoredVerified(_ fingerprint: String, verified: Bool) + func isVerifiedFingerprint(_ fingerprint: String) -> Bool + func saveIdentityState() + + // MARK: Encryption status + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) + func updateEncryptionStatus(for peerID: PeerID) + func invalidateEncryptionCache(for peerID: PeerID?) + /// Signals that verification state changed so observers refresh (e.g. `objectWillChange.send()`). + func notifyUIChanged() + + // MARK: Peers + var unifiedPeers: [BitchatPeer] { get } + var unifiedFavorites: [BitchatPeer] { get } + /// The peer's current entry in the unified peer service, if known. + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? + func unifiedFingerprint(for peerID: PeerID) -> String? + func resolveNickname(for peerID: PeerID) -> String + func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? + func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) + + // MARK: Noise sessions & verification transport + /// Installs the Noise service's session callbacks (single registration point). + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) + /// Resolves the peer's Noise static key from the active Noise session, if any. + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? + /// Our own Noise static public key. + func noiseStaticPublicKeyData() -> Data + func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool + func triggerHandshake(with peerID: PeerID) + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) +} + +extension ChatViewModel: ChatVerificationContext { + // `getFingerprint(for:)`, `verifiedFingerprints`, `saveIdentityState()`, + // `updateEncryptionStatus(for:)`, `invalidateEncryptionCache(for:)`, + // `notifyUIChanged()`, `unifiedPeer(for:)`, `unifiedFingerprint(for:)`, + // `isVerifiedFingerprint(_:)`, `setEncryptionStatus(_:for:)`, + // `resolveNickname(for:)`, `cachedStablePeerID(for:)`, + // `cacheStablePeerID(_:for:)`, `noiseSessionPublicKeyData(for:)`, + // `hasEstablishedNoiseSession(with:)`, and `triggerHandshake(with:)` are + // shared requirements with the other contexts or satisfied by existing + // `ChatViewModel` members. The members below flatten nested service + // accesses into intent-named calls. + + func persistedVerifiedFingerprints() -> Set { + identityManager.getVerifiedFingerprints() + } + + func setIdentityVerified(fingerprint: String, verified: Bool) { + identityManager.setVerified(fingerprint: fingerprint, verified: verified) + } + + func setStoredVerified(_ fingerprint: String, verified: Bool) { + peerIdentityStore.setVerified(fingerprint, verified: verified) + } + + var unifiedPeers: [BitchatPeer] { + unifiedPeerService.peers + } + + var unifiedFavorites: [BitchatPeer] { + unifiedPeerService.favorites + } + + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) { + let noiseService = meshService.getNoiseService() + noiseService.onPeerAuthenticated = onPeerAuthenticated + noiseService.onHandshakeRequired = onHandshakeRequired + } + + func noiseStaticPublicKeyData() -> Data { + meshService.getNoiseService().getStaticPublicKeyData() + } + + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { + meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) + } + + func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { + meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) + } +} + @MainActor final class ChatVerificationCoordinator { struct PendingVerification { @@ -13,44 +122,44 @@ final class ChatVerificationCoordinator { var sent: Bool } - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatVerificationContext private var pendingQRVerifications: [PeerID: PendingVerification] = [:] private var lastVerifyNonceByPeer: [PeerID: Data] = [:] private var lastInboundVerifyChallengeAt: [String: Date] = [:] private var lastMutualToastAt: [String: Date] = [:] - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatVerificationContext) { + self.context = context } func verifyFingerprint(for peerID: PeerID) { - guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return } + guard let fingerprint = context.getFingerprint(for: peerID) else { return } - viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true) - viewModel.saveIdentityState() - viewModel.peerIdentityStore.setVerified(fingerprint, verified: true) - viewModel.updateEncryptionStatus(for: peerID) + context.setIdentityVerified(fingerprint: fingerprint, verified: true) + context.saveIdentityState() + context.setStoredVerified(fingerprint, verified: true) + context.updateEncryptionStatus(for: peerID) } func unverifyFingerprint(for peerID: PeerID) { - guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return } - viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: false) - viewModel.saveIdentityState() - viewModel.peerIdentityStore.setVerified(fingerprint, verified: false) - viewModel.updateEncryptionStatus(for: peerID) + guard let fingerprint = context.getFingerprint(for: peerID) else { return } + context.setIdentityVerified(fingerprint: fingerprint, verified: false) + context.saveIdentityState() + context.setStoredVerified(fingerprint, verified: false) + context.updateEncryptionStatus(for: peerID) } func loadVerifiedFingerprints() { - viewModel.peerIdentityStore.setVerifiedFingerprints(viewModel.identityManager.getVerifiedFingerprints()) - let sample = Array(viewModel.peerIdentityStore.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)) + context.verifiedFingerprints = context.persistedVerifiedFingerprints() + let sample = Array(context.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount)) .map { $0.prefix(8) } .joined(separator: ", ") - SecureLogger.info("๐Ÿ” Verified loaded: \(viewModel.peerIdentityStore.verifiedFingerprints.count) [\(sample)]", category: .security) + SecureLogger.info("๐Ÿ” Verified loaded: \(context.verifiedFingerprints.count) [\(sample)]", category: .security) - let offlineFavorites = viewModel.unifiedPeerService.favorites.filter { !$0.isConnected } + let offlineFavorites = context.unifiedFavorites.filter { !$0.isConnected } for favorite in offlineFavorites { - let fingerprint = viewModel.unifiedPeerService.getFingerprint(for: favorite.peerID) - let isVerified = fingerprint.flatMap { viewModel.peerIdentityStore.isVerified($0) } ?? false + let fingerprint = context.unifiedFingerprint(for: favorite.peerID) + let isVerified = fingerprint.flatMap { context.isVerifiedFingerprint($0) } ?? false let shortFingerprint = fingerprint?.prefix(8) ?? "nil" SecureLogger.info( "โญ๏ธ Favorite offline: \(favorite.nickname) fp=\(shortFingerprint) verified=\(isVerified)", @@ -58,62 +167,61 @@ final class ChatVerificationCoordinator { ) } - viewModel.invalidateEncryptionCache() - viewModel.objectWillChange.send() + context.invalidateEncryptionCache(for: nil) + context.notifyUIChanged() } func setupNoiseCallbacks() { - let noiseService = viewModel.meshService.getNoiseService() + context.installNoiseSessionCallbacks( + onPeerAuthenticated: { [weak self] peerID, fingerprint in + DispatchQueue.main.async { [weak self] in + guard let self else { return } - noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in - DispatchQueue.main.async { [weak self] in - guard let self else { return } + SecureLogger.debug("๐Ÿ” Authenticated: \(peerID)", category: .security) - SecureLogger.debug("๐Ÿ” Authenticated: \(peerID)", category: .security) + if self.context.isVerifiedFingerprint(fingerprint) { + self.context.setEncryptionStatus(.noiseVerified, for: peerID) + } else { + self.context.setEncryptionStatus(.noiseSecured, for: peerID) + } - if self.viewModel.peerIdentityStore.isVerified(fingerprint) { - self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseVerified, for: peerID) - } else { - self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseSecured, for: peerID) + self.context.invalidateEncryptionCache(for: peerID) + + if self.context.cachedStablePeerID(for: peerID) == nil, + let keyData = self.context.noiseSessionPublicKeyData(for: peerID) { + let stablePeerID = PeerID(hexData: keyData) + self.context.cacheStablePeerID(stablePeerID, for: peerID) + SecureLogger.debug( + "๐Ÿ—บ๏ธ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))โ€ฆ", + category: .session + ) + } + + if var pending = self.pendingQRVerifications[peerID], pending.sent == false { + self.context.sendVerifyChallenge( + to: peerID, + noiseKeyHex: pending.noiseKeyHex, + nonceA: pending.nonceA + ) + pending.sent = true + self.pendingQRVerifications[peerID] = pending + SecureLogger.debug("๐Ÿ“ค Sent deferred verify challenge to \(peerID) after handshake", category: .security) + } } - - self.viewModel.invalidateEncryptionCache(for: peerID) - - if self.viewModel.cachedStablePeerID(for: peerID) == nil, - let keyData = self.viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) { - let stablePeerID = PeerID(hexData: keyData) - self.viewModel.cacheStablePeerID(stablePeerID, for: peerID) - SecureLogger.debug( - "๐Ÿ—บ๏ธ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))โ€ฆ", - category: .session - ) - } - - if var pending = self.pendingQRVerifications[peerID], pending.sent == false { - self.viewModel.meshService.sendVerifyChallenge( - to: peerID, - noiseKeyHex: pending.noiseKeyHex, - nonceA: pending.nonceA - ) - pending.sent = true - self.pendingQRVerifications[peerID] = pending - SecureLogger.debug("๐Ÿ“ค Sent deferred verify challenge to \(peerID) after handshake", category: .security) + }, + onHandshakeRequired: { [weak self] peerID in + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.context.setEncryptionStatus(.noiseHandshaking, for: peerID) + self.context.invalidateEncryptionCache(for: peerID) } } - } - - noiseService.onHandshakeRequired = { [weak self] peerID in - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID) - self.viewModel.invalidateEncryptionCache(for: peerID) - } - } + ) } func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool { let targetNoise = qr.noiseKeyHex.lowercased() - guard let peer = viewModel.unifiedPeerService.peers.first(where: { + guard let peer = context.unifiedPeers.first(where: { $0.noisePublicKey.hexEncodedString().lowercased() == targetNoise }) else { return false @@ -135,13 +243,12 @@ final class ChatVerificationCoordinator { ) pendingQRVerifications[peerID] = pending - let noise = viewModel.meshService.getNoiseService() - if noise.hasEstablishedSession(with: peerID) { - viewModel.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) + if context.hasEstablishedNoiseSession(with: peerID) { + context.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce) pending.sent = true pendingQRVerifications[peerID] = pending } else { - viewModel.meshService.triggerHandshake(with: peerID) + context.triggerHandshake(with: peerID) } return true @@ -150,9 +257,7 @@ final class ChatVerificationCoordinator { func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) { guard let challenge = VerificationService.shared.parseVerifyChallenge(payload) else { return } - let myNoiseHex = viewModel.meshService - .getNoiseService() - .getStaticPublicKeyData() + let myNoiseHex = context.noiseStaticPublicKeyData() .hexEncodedString() .lowercased() guard challenge.noiseKeyHex.lowercased() == myNoiseHex else { return } @@ -160,22 +265,22 @@ final class ChatVerificationCoordinator { lastVerifyNonceByPeer[peerID] = challenge.nonceA - if let fingerprint = viewModel.getFingerprint(for: peerID) { + if let fingerprint = context.getFingerprint(for: peerID) { lastInboundVerifyChallengeAt[fingerprint] = Date() - if viewModel.peerIdentityStore.isVerified(fingerprint) { + if context.isVerifiedFingerprint(fingerprint) { maybeSendMutualVerificationNotification( fingerprint: fingerprint, peerID: peerID, title: "Mutual verification", - bodyName: viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname - ?? viewModel.resolveNickname(for: peerID), + bodyName: context.unifiedPeer(for: peerID)?.nickname + ?? context.resolveNickname(for: peerID), notificationPrefix: "verify-mutual" ) } } - viewModel.meshService.sendVerifyResponse( + context.sendVerifyResponse( to: peerID, noiseKeyHex: challenge.noiseKeyHex, nonceA: challenge.nonceA @@ -198,16 +303,16 @@ final class ChatVerificationCoordinator { pendingQRVerifications.removeValue(forKey: peerID) - guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return } + guard let fingerprint = context.getFingerprint(for: peerID) else { return } let shortFingerprint = fingerprint.prefix(8) SecureLogger.info("๐Ÿ” Marking verified fingerprint: \(shortFingerprint)", category: .security) - viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true) - viewModel.saveIdentityState() - viewModel.peerIdentityStore.setVerified(fingerprint, verified: true) + context.setIdentityVerified(fingerprint: fingerprint, verified: true) + context.saveIdentityState() + context.setStoredVerified(fingerprint, verified: true) - let peerName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname - ?? viewModel.resolveNickname(for: peerID) + let peerName = context.unifiedPeer(for: peerID)?.nickname + ?? context.resolveNickname(for: peerID) NotificationService.shared.sendLocalNotification( title: "Verified", body: "You verified \(peerName)", @@ -225,7 +330,7 @@ final class ChatVerificationCoordinator { ) } - viewModel.updateEncryptionStatus(for: peerID) + context.updateEncryptionStatus(for: peerID) } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 23667420..e5e3baa6 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -147,18 +147,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele let autocompleteService: AutocompleteService let deduplicationService: MessageDeduplicationService // internal for test access private lazy var outgoingCoordinator = ChatOutgoingCoordinator(viewModel: self) - private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(viewModel: self) - private lazy var transportEventCoordinator = ChatTransportEventCoordinator(viewModel: self) + private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(context: self) + private lazy var transportEventCoordinator = ChatTransportEventCoordinator(context: self) private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self) private lazy var messageFormatter = ChatMessageFormatter(viewModel: self) - lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(viewModel: self) + lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self) lazy var deliveryCoordinator = ChatDeliveryCoordinator(context: self) lazy var composerCoordinator = ChatComposerCoordinator(viewModel: self) lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self) lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self) lazy var nostrCoordinator = ChatNostrCoordinator(context: self) - lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(viewModel: self) - lazy var verificationCoordinator = ChatVerificationCoordinator(viewModel: self) + lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self) + lazy var verificationCoordinator = ChatVerificationCoordinator(context: self) // Computed properties for compatibility @MainActor @@ -453,6 +453,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele sentGeoDeliveryAcks.insert(messageID).inserted } + /// Forgets that read receipts were sent for `ids` so READ acks can be + /// re-sent after the peer reconnects. + @MainActor + func unmarkReadReceiptsSent(_ ids: [String]) { + sentReadReceipts.subtract(ids) + } + + /// Marks read receipts as sent for own messages already delivered/read in + /// `peerID`'s chat, syncing the chat manager's tracking with the persisted + /// set. (Wraps the manager's `inout` sync so the raw set never leaks.) + @MainActor + func syncReadReceiptsForSentMessages(for peerID: PeerID) { + privateChatManager.syncReadReceiptsForSentMessages( + peerID: peerID, + nickname: nickname, + externalReceipts: &sentReadReceipts + ) + } + /// Drops every recorded read receipt whose message ID is no longer valid. /// Returns the number of receipts removed. @MainActor diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift new file mode 100644 index 00000000..12693601 --- /dev/null +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -0,0 +1,283 @@ +// +// ChatLifecycleCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatLifecycleCoordinator` against a mock `ChatLifecycleContext` +// โ€” proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// Scope note: the mesh/Nostr read-receipt branch of +// `markPrivateMessagesAsRead` consults `FavoritesPersistenceService.shared` +// and the geohash-screenshot branch publishes via `NostrRelayManager.shared` / +// `GeoRelayDirectory.shared`; those stay covered by the full view-model tests. +// The GeoDM read pass, message merging, screenshot notices, and lifecycle +// persistence flows are covered here. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatLifecycleContext` proving that +/// `ChatLifecycleCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatLifecycleContext: ChatLifecycleContext { + // Chat & receipt state + var messages: [BitchatMessage] = [] + var privateChats: [PeerID: [BitchatMessage]] = [:] + var unreadPrivateMessages: Set = [] + var selectedPrivateChatPeer: PeerID? + var sentReadReceipts: Set = [] + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var activeChannel: ChannelID = .mesh + var nostrKeyMapping: [PeerID: String] = [:] + private(set) var ownerLevelReadPasses: [PeerID] = [] + private(set) var managerReadMarks: [PeerID] = [] + private(set) var privateStoreSyncCount = 0 + private(set) var systemMessages: [String] = [] + + @discardableResult + func markReadReceiptSent(_ messageID: String) -> Bool { + sentReadReceipts.insert(messageID).inserted + } + + func markPrivateMessagesAsRead(from peerID: PeerID) { + ownerLevelReadPasses.append(peerID) + } + + func markChatAsRead(from peerID: PeerID) { + managerReadMarks.append(peerID) + } + + func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 } + func addSystemMessage(_ content: String) { systemMessages.append(content) } + + // Peers & sessions + var nicknamesByPeerID: [PeerID: String] = [:] + var peersByID: [PeerID: BitchatPeer] = [:] + var noiseSessionStates: [PeerID: LazyHandshakeState] = [:] + private(set) var stopMeshServicesCount = 0 + private(set) var refreshBluetoothStateCount = 0 + + func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] } + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] } + func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState { + noiseSessionStates[peerID] ?? .none + } + func stopMeshServices() { stopMeshServicesCount += 1 } + func refreshBluetoothState() { refreshBluetoothStateCount += 1 } + + // Routing & receipts + private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = [] + private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = [] + private(set) var meshBroadcasts: [String] = [] + private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] + + func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { + routedPrivateMessages.append((content, peerID, recipientNickname)) + } + + func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { + routedReadReceipts.append((receipt.originalMessageID, peerID)) + } + + func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { + meshBroadcasts.append(content) + } + + func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { + geoReadReceipts.append((messageID, recipientHex)) + } + + // Nostr & geohash + var isTeleported = false + private(set) var recordedGeoParticipants: [String] = [] + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity } + func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) } + + // Identity persistence + private(set) var forceSaveIdentityCount = 0 + private(set) var verifyIdentityKeyExistsCount = 0 + + func forceSaveIdentity() { forceSaveIdentityCount += 1 } + + @discardableResult + func verifyIdentityKeyExists() -> Bool { + verifyIdentityKeyExistsCount += 1 + return true + } + + static let dummyIdentity = NostrIdentity( + privateKey: Data(repeating: 0x11, count: 32), + publicKey: Data(repeating: 0x22, count: 32), + npub: "npub1mock", + createdAt: Date(timeIntervalSince1970: 0) + ) +} + +// MARK: - Helpers + +@MainActor +private func makePrivateMessage( + id: String, + sender: String = "alice", + timestamp: Date = Date(), + senderPeerID: PeerID? = nil, + isRelay: Bool = false, + deliveryStatus: DeliveryStatus? = nil +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: sender, + content: "hello", + timestamp: timestamp, + isRelay: isRelay, + isPrivate: true, + recipientNickname: "me", + senderPeerID: senderPeerID, + deliveryStatus: deliveryStatus + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatLifecycleCoordinator` against `MockChatLifecycleContext` +/// with no `ChatViewModel`. +struct ChatLifecycleCoordinatorContextTests { + + @Test @MainActor + func getPrivateChatMessages_mergesEphemeralAndStableKeepingBestStatus() async { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0xAB, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") + + let t1 = Date(timeIntervalSince1970: 1) + let t2 = Date(timeIntervalSince1970: 2) + // Same message under both keys: the read copy must win over sent. + context.privateChats[peerID] = [ + makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent), + makePrivateMessage(id: "m2", timestamp: t2), + ] + context.privateChats[stablePeerID] = [ + makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)), + ] + + let merged = coordinator.getPrivateChatMessages(for: peerID) + #expect(merged.map(\.id) == ["m1", "m2"]) + if case .read? = merged.first?.deliveryStatus { + } else { + Issue.record("expected the .read copy of m1 to win the merge") + } + + // getMessages(for: nil) falls back to the public timeline. + context.messages = [makePrivateMessage(id: "pub")] + #expect(coordinator.getMessages(for: nil).map(\.id) == ["pub"]) + } + + @Test @MainActor + func markPrivateMessagesAsRead_geoDM_sendsReadReceiptsOnce() async { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + let convKey = PeerID(nostr_: "feedface00112233") + let recipientHex = "feedface00112233" + context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy")) + context.nostrKeyMapping[convKey] = recipientHex + context.sentReadReceipts = ["already-acked"] + context.privateChats[convKey] = [ + makePrivateMessage(id: "m1", senderPeerID: convKey), + makePrivateMessage(id: "already-acked", senderPeerID: convKey), + makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true), + makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID), + ] + + coordinator.markPrivateMessagesAsRead(from: convKey) + + #expect(context.managerReadMarks == [convKey]) + #expect(context.privateStoreSyncCount == 1) + // Only the peer's own un-acked, non-relay message gets a READ. + #expect(context.geoReadReceipts.map(\.messageID) == ["m1"]) + #expect(context.geoReadReceipts.first?.recipientHex == recipientHex) + #expect(context.sentReadReceipts.contains("m1")) + + // Second pass: nothing new to send. + coordinator.markPrivateMessagesAsRead(from: convKey) + #expect(context.geoReadReceipts.count == 1) + } + + @Test @MainActor + func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.selectedPrivateChatPeer = peerID + context.nicknamesByPeerID[peerID] = "alice" + + // No established session: local notice only, no network send. + coordinator.handleScreenshotCaptured() + #expect(context.routedPrivateMessages.isEmpty) + #expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"]) + #expect(context.privateChats[peerID]?.first?.sender == "system") + + // Established session: the peer is notified too. + context.noiseSessionStates[peerID] = .established + coordinator.handleScreenshotCaptured() + #expect(context.routedPrivateMessages.count == 1) + #expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *") + #expect(context.routedPrivateMessages.first?.recipientNickname == "alice") + #expect(context.privateChats[peerID]?.count == 2) + // The public-channel system message is not used for private chats. + #expect(context.systemMessages.isEmpty) + #expect(context.meshBroadcasts.isEmpty) + } + + @Test @MainActor + func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() async { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + + coordinator.handleScreenshotCaptured() + + #expect(context.meshBroadcasts == ["* me took a screenshot *"]) + #expect(context.systemMessages == ["you took a screenshot"]) + #expect(context.privateChats.isEmpty) + } + + @Test @MainActor + func lifecycleEvents_persistIdentityAndScheduleReadPasses() async { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + + coordinator.applicationWillTerminate() + #expect(context.stopMeshServicesCount == 1) + #expect(context.forceSaveIdentityCount == 1) + #expect(context.verifyIdentityKeyExistsCount == 1) + + // Becoming active with no open chat only refreshes Bluetooth state. + coordinator.handleDidBecomeActive() + #expect(context.refreshBluetoothStateCount == 1) + #expect(context.managerReadMarks.isEmpty) + + // With an open chat the read pass runs immediately (manager-level) and + // a delayed owner-level pass is scheduled. + let peerID = PeerID(nostr_: "feedface00112233") + context.selectedPrivateChatPeer = peerID + coordinator.handleDidBecomeActive() + #expect(context.refreshBluetoothStateCount == 2) + #expect(context.managerReadMarks == [peerID]) + + let deadline = Date().addingTimeInterval(2) + while context.ownerLevelReadPasses.isEmpty && Date() < deadline { + try? await Task.sleep(nanoseconds: 20_000_000) + } + #expect(context.ownerLevelReadPasses == [peerID]) + } +} diff --git a/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift new file mode 100644 index 00000000..12e639a1 --- /dev/null +++ b/bitchatTests/ChatMediaTransferCoordinatorContextTests.swift @@ -0,0 +1,206 @@ +// +// ChatMediaTransferCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatMediaTransferCoordinator` against a mock +// `ChatMediaTransferContext` โ€” proving the coordinator works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// Scope note: the async media-preparation pipelines (`ImageUtils`, +// `ChatMediaPreparation`) run real file/codec work and remain covered by +// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer +// bookkeeping, and the blocked-context guards. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatMediaTransferContext` proving that +/// `ChatMediaTransferCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatMediaTransferContext: ChatMediaTransferContext { + // Composition state + var canSendMediaInCurrentContext = true + var selectedPrivateChatPeer: PeerID? + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var activeChannel: ChannelID = .mesh + var nicknamesByPeerID: [PeerID: String] = [:] + + func nicknameForPeer(_ peerID: PeerID) -> String { + nicknamesByPeerID[peerID] ?? "user" + } + + func currentPublicSender() -> (name: String, peerID: PeerID) { + (nickname, myPeerID) + } + + // Message state + var privateChats: [PeerID: [BitchatMessage]] = [:] + var meshTimeline: [BitchatMessage] = [] + private(set) var refreshedChannels: [ChannelID?] = [] + private(set) var trimCount = 0 + private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = [] + private(set) var systemMessages: [String] = [] + private(set) var notifyUIChangedCount = 0 + + func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) { + meshTimeline.append(message) + } + + func refreshVisibleMessages(from channel: ChannelID?) { + refreshedChannels.append(channel) + } + + func trimMessagesIfNeeded() { trimCount += 1 } + + func removeMessage(withID messageID: String, cleanupFile: Bool) { + removedMessages.append((messageID, cleanupFile)) + } + + func addSystemMessage(_ content: String) { systemMessages.append(content) } + func notifyUIChanged() { notifyUIChangedCount += 1 } + + // Delivery status & dedup + private(set) var deliveryStatusUpdates: [(messageID: String, status: DeliveryStatus)] = [] + private(set) var recordedContentKeys: [String] = [] + + func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) { + deliveryStatusUpdates.append((messageID, status)) + } + + func normalizedContentKey(_ content: String) -> String { content.lowercased() } + + func recordContentKey(_ key: String, timestamp: Date) { + recordedContentKeys.append(key) + } + + // Mesh file transfer + private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = [] + private(set) var broadcastFileSends: [String] = [] + private(set) var cancelledTransfers: [String] = [] + + func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { + privateFileSends.append((peerID, transferId)) + } + + func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { + broadcastFileSends.append(transferId) + } + + func cancelTransfer(_ transferId: String) { + cancelledTransfers.append(transferId) + } +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatMediaTransferCoordinator` against +/// `MockChatMediaTransferContext` with no `ChatViewModel`. +struct ChatMediaTransferCoordinatorContextTests { + + @Test @MainActor + func enqueueMediaMessage_privateChatAppendsAndRecordsDedupKey() async { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.nicknamesByPeerID[peerID] = "alice" + + let message = coordinator.enqueueMediaMessage(content: "[voice] note.m4a", targetPeer: peerID) + + #expect(context.privateChats[peerID]?.map(\.id) == [message.id]) + #expect(message.isPrivate) + #expect(message.recipientNickname == "alice") + #expect(message.senderPeerID == context.myPeerID) + #expect(message.deliveryStatus == .sending) + #expect(context.recordedContentKeys == ["[voice] note.m4a"]) + #expect(context.trimCount == 1) + #expect(context.notifyUIChangedCount == 1) + #expect(context.meshTimeline.isEmpty) + } + + @Test @MainActor + func enqueueMediaMessage_publicAppendsToTimelineAndRefreshes() async { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + + let message = coordinator.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: nil) + + #expect(context.meshTimeline.map(\.id) == [message.id]) + #expect(!message.isPrivate) + #expect(message.sender == "me") + #expect(context.refreshedChannels.count == 1) + #expect(context.privateChats.isEmpty) + #expect(context.notifyUIChangedCount == 1) + } + + @Test @MainActor + func transferEvents_driveDeliveryStatusAndMappingCleanup() async { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + coordinator.registerTransfer(transferId: "t1", messageID: "m1") + + coordinator.handleTransferEvent(.started(id: "t1", totalFragments: 10)) + coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 4, totalFragments: 10)) + coordinator.handleTransferEvent(.completed(id: "t1", totalFragments: 10)) + // After completion the mapping is gone: further events are ignored. + coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 9, totalFragments: 10)) + + #expect(context.deliveryStatusUpdates.count == 3) + #expect(context.deliveryStatusUpdates[0].status == .partiallyDelivered(reached: 0, total: 10)) + #expect(context.deliveryStatusUpdates[1].status == .partiallyDelivered(reached: 4, total: 10)) + #expect(context.deliveryStatusUpdates[2].status == .sent) + #expect(coordinator.messageIDToTransferId.isEmpty) + + // A cancelled transfer removes the message (with file cleanup). + coordinator.registerTransfer(transferId: "t2", messageID: "m2") + coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5)) + #expect(context.removedMessages.count == 1) + #expect(context.removedMessages.first?.messageID == "m2") + #expect(context.removedMessages.first?.cleanupFile == true) + } + + @Test @MainActor + func cancelMediaSend_cancelsOnlyActiveTransferAndRemovesMessage() async { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + // Two messages share a transfer queue; only the active head cancels + // the underlying transfer. + coordinator.registerTransfer(transferId: "t1", messageID: "m1") + coordinator.registerTransfer(transferId: "t1", messageID: "m2") + + coordinator.cancelMediaSend(messageID: "m2") + #expect(context.cancelledTransfers.isEmpty) + #expect(context.removedMessages.map(\.messageID) == ["m2"]) + + coordinator.cancelMediaSend(messageID: "m1") + #expect(context.cancelledTransfers == ["t1"]) + #expect(context.removedMessages.map(\.messageID) == ["m2", "m1"]) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + #expect(coordinator.messageIDToTransferId.isEmpty) + } + + @Test @MainActor + func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws { + let context = MockChatMediaTransferContext() + let coordinator = ChatMediaTransferCoordinator(context: context) + context.canSendMediaInCurrentContext = false + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("voice-note-test-\(UUID().uuidString).m4a") + try Data([0x01, 0x02]).write(to: url) + + coordinator.sendVoiceNote(at: url) + + #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(context.systemMessages == ["Voice notes are only available in mesh chats."]) + #expect(context.privateChats.isEmpty) + #expect(context.meshTimeline.isEmpty) + #expect(coordinator.transferIdToMessageIDs.isEmpty) + } +} diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift new file mode 100644 index 00000000..bf0cbddb --- /dev/null +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -0,0 +1,358 @@ +// +// ChatPeerIdentityCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatPeerIdentityCoordinator` against a mock +// `ChatPeerIdentityContext` โ€” proving the coordinator works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// Scope note: flows that hit the `FavoritesPersistenceService.shared` +// singleton (`isFavorite` / `toggleFavoriteForNoiseKey` / favorite +// notifications / `nicknameForPeer` fallbacks) remain covered by the full +// view-model tests; the session, migration, encryption-status, and nickname +// resolution flows are covered here. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatPeerIdentityContext` proving that +/// `ChatPeerIdentityCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { + // Conversation state + var privateChats: [PeerID: [BitchatMessage]] = [:] + var unreadPrivateMessages: Set = [] + var selectedPrivateChatPeer: PeerID? + var selectedPrivateChatFingerprint: String? + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var activeChannel: ChannelID = .mesh + private(set) var notifyUIChangedCount = 0 + private(set) var systemMessages: [String] = [] + + func notifyUIChanged() { notifyUIChangedCount += 1 } + func addSystemMessage(_ content: String) { systemMessages.append(content) } + + // Private chat session lifecycle + private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = [] + private(set) var syncedReadReceiptPeers: [PeerID] = [] + private(set) var begunChatSessions: [PeerID] = [] + private(set) var privateStoreSyncCount = 0 + private(set) var selectionStoreSyncCount = 0 + private(set) var markedReadPeers: [PeerID] = [] + + @discardableResult + func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool { + consolidatedPeers.append((peerID, peerNickname)) + return false + } + + func syncReadReceiptsForSentMessages(for peerID: PeerID) { + syncedReadReceiptPeers.append(peerID) + } + + func beginPrivateChatSession(with peerID: PeerID) { + begunChatSessions.append(peerID) + } + + func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 } + func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 } + func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) } + + // Unified peer service + var connectedPeers: Set = [] + var peersByID: [PeerID: BitchatPeer] = [:] + var blockedPeers: Set = [] + var fingerprintsByPeerID: [PeerID: String] = [:] + var peerIDsByNickname: [String: PeerID] = [:] + var ephemeralPeerIDsByNoiseKey: [Data: PeerID] = [:] + private(set) var toggledFavoritePeers: [PeerID] = [] + + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] } + func unifiedIsBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } + func unifiedToggleFavorite(_ peerID: PeerID) { toggledFavoritePeers.append(peerID) } + func unifiedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + func unifiedPeerID(forNickname nickname: String) -> PeerID? { peerIDsByNickname[nickname] } + func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { ephemeralPeerIDsByNoiseKey[noiseKey] } + + // Mesh & Noise sessions + var nicknamesByPeerID: [PeerID: String] = [:] + var noiseSessionStates: [PeerID: LazyHandshakeState] = [:] + var establishedNoiseSessions: Set = [] + var activeNoiseSessions: Set = [] + var myNoiseFingerprint = "my-fingerprint" + private(set) var triggeredHandshakes: [PeerID] = [] + + func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] } + func meshPeerNicknames() -> [PeerID: String] { nicknamesByPeerID } + func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState { + noiseSessionStates[peerID] ?? .none + } + func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) } + func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { + establishedNoiseSessions.contains(peerID) + } + func hasNoiseSession(with peerID: PeerID) -> Bool { activeNoiseSessions.contains(peerID) } + func noiseIdentityFingerprint() -> String { myNoiseFingerprint } + + // Identity store (fingerprints & encryption status) + var verifiedFingerprintSet: Set = [] + var socialIdentitiesByFingerprint: [String: SocialIdentity] = [:] + private(set) var storedFingerprints: [(fingerprint: String, peerID: PeerID)] = [] + private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:] + private(set) var cachedEncryptionStatuses: [PeerID: EncryptionStatus] = [:] + private(set) var invalidatedEncryptionCachePeers: [PeerID?] = [] + + func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) { + storedFingerprints.append((fingerprint, peerID)) + fingerprintsByPeerID[peerID] = fingerprint + } + + func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? { + let fingerprint = fingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback + if let fingerprint { + fingerprintsByPeerID[newPeerID] = fingerprint + } + return fingerprint + } + + func isVerifiedFingerprint(_ fingerprint: String) -> Bool { + verifiedFingerprintSet.contains(fingerprint) + } + + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { + encryptionStatuses[peerID] = status + } + + func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? { + cachedEncryptionStatuses[peerID] + } + + func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) { + cachedEncryptionStatuses[peerID] = status + } + + func invalidateStoredEncryptionCache(for peerID: PeerID?) { + invalidatedEncryptionCachePeers.append(peerID) + if let peerID { + cachedEncryptionStatuses.removeValue(forKey: peerID) + } else { + cachedEncryptionStatuses.removeAll() + } + } + + func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? { + socialIdentitiesByFingerprint[fingerprint] + } + + // Geohash & Nostr + var geoNicknames: [String: String] = [:] + var geohashPeople: [GeoPerson] = [] + private(set) var registeredNostrKeyMappings: [(pubkey: String, peerID: PeerID)] = [] + private(set) var nostrFavoriteNotifications: [(noisePublicKey: Data, isFavorite: Bool)] = [] + var bridgedNostrKeysByNoiseKey: [Data: String] = [:] + + func visibleGeohashPeople() -> [GeoPerson] { geohashPeople } + + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { + registeredNostrKeyMappings.append((pubkey, peerID)) + } + + func bridgedNostrPublicKey(for noiseKey: Data) -> String? { + bridgedNostrKeysByNoiseKey[noiseKey] + } + + func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { + nostrFavoriteNotifications.append((noisePublicKey, isFavorite)) + } +} + +// MARK: - Helpers + +@MainActor +private func makePrivateMessage( + id: String, + sender: String = "alice", + timestamp: Date = Date(), + senderPeerID: PeerID? = nil +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: sender, + content: "hello", + timestamp: timestamp, + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: senderPeerID + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatPeerIdentityCoordinator` against +/// `MockChatPeerIdentityContext` with no `ChatViewModel`. +struct ChatPeerIdentityCoordinatorContextTests { + + @Test @MainActor + func startPrivateChat_runsFullSessionSetupSequence() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.nicknamesByPeerID[peerID] = "alice" + context.fingerprintsByPeerID[peerID] = "fp-alice" + + // Chatting with ourselves is a no-op. + coordinator.startPrivateChat(with: context.myPeerID) + #expect(context.begunChatSessions.isEmpty) + + coordinator.startPrivateChat(with: peerID) + + #expect(context.consolidatedPeers.map(\.peerID) == [peerID]) + #expect(context.consolidatedPeers.first?.peerNickname == "alice") + // No Noise session yet -> handshake triggered. + #expect(context.triggeredHandshakes == [peerID]) + #expect(context.syncedReadReceiptPeers == [peerID]) + #expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"]) + #expect(context.selectedPrivateChatFingerprint == "fp-alice") + #expect(context.begunChatSessions == [peerID]) + #expect(context.privateStoreSyncCount == 1) + #expect(context.selectionStoreSyncCount == 1) + #expect(context.markedReadPeers == [peerID]) + + // Established session: no second handshake. + context.noiseSessionStates[peerID] = .established + coordinator.startPrivateChat(with: peerID) + #expect(context.triggeredHandshakes == [peerID]) + } + + @Test @MainActor + func startPrivateChat_blockedPeerOnlyGetsSystemMessage() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + context.blockedPeers = [peerID] + + coordinator.startPrivateChat(with: peerID) + + #expect(context.systemMessages.count == 1) + #expect(context.begunChatSessions.isEmpty) + #expect(context.consolidatedPeers.isEmpty) + #expect(context.markedReadPeers.isEmpty) + } + + @Test @MainActor + func updatePrivateChatPeerIfNeeded_migratesChatStateByFingerprint() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let oldPeerID = PeerID(str: "1111111111111111") + let newPeerID = PeerID(str: "2222222222222222") + + context.selectedPrivateChatFingerprint = "fp" + context.selectedPrivateChatPeer = oldPeerID + context.connectedPeers = [newPeerID] + context.fingerprintsByPeerID[newPeerID] = "fp" + let earlier = makePrivateMessage(id: "m1", timestamp: Date(timeIntervalSince1970: 1)) + let later = makePrivateMessage(id: "m2", timestamp: Date(timeIntervalSince1970: 2)) + context.privateChats[oldPeerID] = [later] + context.privateChats[newPeerID] = [earlier, later] // duplicate id "m2" + context.unreadPrivateMessages = [oldPeerID] + + coordinator.updatePrivateChatPeerIfNeeded() + + // Old chat is merged into the new peer's chat, deduplicated by id and + // sorted by timestamp; old keys are dropped. + #expect(context.privateChats[oldPeerID] == nil) + #expect(context.privateChats[newPeerID]?.map(\.id) == ["m1", "m2"]) + #expect(context.selectedPrivateChatPeer == newPeerID) + // Unread moved to the new peer, then cleared for the now-open chat. + #expect(context.unreadPrivateMessages.isEmpty) + } + + @Test @MainActor + func getEncryptionStatus_computesVerifiedStatusAndCachesIt() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + + // Unknown peer with no fingerprint or session: no handshake yet. + #expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake) + #expect(context.cachedEncryptionStatuses[peerID] == .noHandshake) + + // Cache hit short-circuits recomputation. + context.noiseSessionStates[peerID] = .established + #expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake) + + // After invalidation, an established session with a verified + // fingerprint resolves (and re-caches) as verified. + coordinator.invalidateEncryptionCache(for: peerID) + context.fingerprintsByPeerID[peerID] = "fp" + context.verifiedFingerprintSet = ["fp"] + #expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified) + #expect(context.cachedEncryptionStatuses[peerID] == .noiseVerified) + + // updateEncryptionStatus publishes to the store and invalidates the cache. + context.establishedNoiseSessions = [peerID] + coordinator.updateEncryptionStatus(for: peerID) + #expect(context.encryptionStatuses[peerID] == .noiseVerified) + #expect(context.cachedEncryptionStatuses[peerID] == nil) + } + + @Test @MainActor + func resolveNickname_walksMeshIdentityAndAnonFallbacks() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let meshPeer = PeerID(str: "aabbccddeeff0011") + let identityPeer = PeerID(str: "1234567890abcdef") + let unknownPeer = PeerID(str: "feedfacefeedface") + + context.nicknamesByPeerID[meshPeer] = "alice" + #expect(coordinator.resolveNickname(for: meshPeer) == "alice") + + context.fingerprintsByPeerID[identityPeer] = "fp" + context.socialIdentitiesByFingerprint["fp"] = SocialIdentity( + fingerprint: "fp", + localPetname: "bob!", + claimedNickname: "bob", + trustLevel: .casual, + isFavorite: false, + isBlocked: false, + notes: nil + ) + #expect(coordinator.resolveNickname(for: identityPeer) == "bob!") + + #expect(coordinator.resolveNickname(for: unknownPeer) == "anonfeed") + #expect(coordinator.getMyFingerprint() == "my-fingerprint") + } + + @Test @MainActor + func getPeerIDForNickname_inGeohashChannel_registersNostrMapping() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let pubkey = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".lowercased() + context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy")) + context.geohashPeople = [GeoPerson(id: pubkey, displayName: "alice#6789", lastSeen: Date())] + context.geoNicknames[pubkey] = "alice" + + // Suffixed display-name match. + let bySuffix = coordinator.getPeerIDForNickname("alice#6789") + #expect(bySuffix == PeerID(nostr_: pubkey)) + // Base-nickname match via geoNicknames. + let byBase = coordinator.getPeerIDForNickname("ALICE") + #expect(byBase == PeerID(nostr_: pubkey)) + #expect(context.registeredNostrKeyMappings.count == 2) + #expect(context.registeredNostrKeyMappings.allSatisfy { $0.pubkey == pubkey }) + + // Mesh channel falls through to the unified peer service. + context.activeChannel = .mesh + let meshPeer = PeerID(str: "1122334455667788") + context.peerIDsByNickname["carol"] = meshPeer + #expect(coordinator.getPeerIDForNickname("carol") == meshPeer) + } +} diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift new file mode 100644 index 00000000..9b6ec7ce --- /dev/null +++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift @@ -0,0 +1,331 @@ +// +// ChatTransportEventCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatTransportEventCoordinator` against a mock +// `ChatTransportEventContext` โ€” proving the coordinator works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// Scope note: the coordinator hops every event onto the main actor via an +// internal `Task`; tests drain those tasks with `Task.yield()`. All flows are +// mockable โ€” no singletons are involved at this layer. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatTransportEventContext` proving that +/// `ChatTransportEventCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatTransportEventContext: ChatTransportEventContext { + // Connection & chat state + var isConnected = false + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var privateChats: [PeerID: [BitchatMessage]] = [:] + var unreadPrivateMessages: Set = [] + var selectedPrivateChatPeer: PeerID? + private(set) var unmarkedReadReceiptBatches: [[String]] = [] + private(set) var notifyUIChangedCount = 0 + + func unmarkReadReceiptsSent(_ ids: [String]) { + unmarkedReadReceiptBatches.append(ids) + } + + func notifyUIChanged() { + notifyUIChangedCount += 1 + } + + // Inbound message handling + var blockedMessageIDs: Set = [] + private(set) var handledPrivateMessages: [BitchatMessage] = [] + private(set) var handledPublicMessages: [BitchatMessage] = [] + private(set) var mentionCheckedMessageIDs: [String] = [] + private(set) var hapticMessageIDs: [String] = [] + + func isMessageBlocked(_ message: BitchatMessage) -> Bool { + blockedMessageIDs.contains(message.id) + } + + func handlePrivateMessage(_ message: BitchatMessage) { + handledPrivateMessages.append(message) + } + + func handlePublicMessage(_ message: BitchatMessage) { + handledPublicMessages.append(message) + } + + func checkForMentions(_ message: BitchatMessage) { + mentionCheckedMessageIDs.append(message.id) + } + + func sendHapticFeedback(for message: BitchatMessage) { + hapticMessageIDs.append(message.id) + } + + func parseMentions(from content: String) -> [String] { + content.contains("@me") ? ["me"] : [] + } + + // Peer identity & sessions + var blockedPeers: Set = [] + var peersByID: [PeerID: BitchatPeer] = [:] + var noiseSessionKeysByPeerID: [PeerID: Data] = [:] + private(set) var stablePeerIDCache: [PeerID: PeerID] = [:] + private(set) var registeredEphemeralSessions: [PeerID] = [] + private(set) var removedEphemeralSessions: [PeerID] = [] + + func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) } + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] } + func resolveNickname(for peerID: PeerID) -> String { "anon\(peerID.id.prefix(4))" } + func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) } + func removeEphemeralSession(peerID: PeerID) { removedEphemeralSessions.append(peerID) } + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { noiseSessionKeysByPeerID[peerID] } + func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) { + stablePeerIDCache[shortPeerID] = stablePeerID + } + func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { stablePeerIDCache[shortPeerID] } + + // Routing & acknowledgements + private(set) var flushedOutboxPeerIDs: [PeerID] = [] + private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] + + func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) } + func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { + meshDeliveryAcks.append((messageID, peerID)) + } + + // Delivery status + var applyMessageDeliveryStatusResult = true + var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:] + private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = [] + + @discardableResult + func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { + appliedDeliveryStatuses.append((messageID, status)) + return applyMessageDeliveryStatusResult + } + + func deliveryStatus(for messageID: String) -> DeliveryStatus? { + deliveryStatusesByMessageID[messageID] + } + + // Verification payloads + private(set) var verifyChallengePayloads: [(peerID: PeerID, payload: Data)] = [] + private(set) var verifyResponsePayloads: [(peerID: PeerID, payload: Data)] = [] + + func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) { + verifyChallengePayloads.append((peerID, payload)) + } + + func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) { + verifyResponsePayloads.append((peerID, payload)) + } +} + +// MARK: - Helpers + +/// Lets the coordinator's internal `Task { @MainActor โ€ฆ }` hops run. +@MainActor +private func drainMainActorTasks() async { + for _ in 0..<10 { await Task.yield() } +} + +private func makeMessage( + id: String, + sender: String = "alice", + content: String = "hello", + isPrivate: Bool = false, + senderPeerID: PeerID? = nil +) -> BitchatMessage { + BitchatMessage( + id: id, + sender: sender, + content: content, + timestamp: Date(), + isRelay: false, + isPrivate: isPrivate, + recipientNickname: isPrivate ? "me" : nil, + senderPeerID: senderPeerID + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatTransportEventCoordinator` against +/// `MockChatTransportEventContext` with no `ChatViewModel`. +struct ChatTransportEventCoordinatorContextTests { + + @Test @MainActor + func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + + // Blocked messages are dropped before any handling. + context.blockedMessageIDs = ["blocked"] + coordinator.didReceiveMessage(makeMessage(id: "blocked")) + // Empty public content is dropped too. + coordinator.didReceiveMessage(makeMessage(id: "empty", content: " ")) + await drainMainActorTasks() + #expect(context.handledPublicMessages.isEmpty) + #expect(context.handledPrivateMessages.isEmpty) + #expect(context.mentionCheckedMessageIDs.isEmpty) + + // Private goes to the private handler, public to the public handler; + // both get mention checks and haptics. + coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true)) + coordinator.didReceiveMessage(makeMessage(id: "pub")) + await drainMainActorTasks() + #expect(context.handledPrivateMessages.map(\.id) == ["pm"]) + #expect(context.handledPublicMessages.map(\.id) == ["pub"]) + #expect(context.mentionCheckedMessageIDs == ["pm", "pub"]) + #expect(context.hapticMessageIDs == ["pm", "pub"]) + } + + @Test @MainActor + func didReceivePublicMessage_trimsContentAndParsesMentions() async { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "aabbccdd00112233") + + coordinator.didReceivePublicMessage( + from: peerID, + nickname: "alice", + content: " hi @me ", + timestamp: Date(), + messageID: "m1" + ) + await drainMainActorTasks() + + #expect(context.handledPublicMessages.count == 1) + let message = context.handledPublicMessages[0] + #expect(message.content == "hi @me") + #expect(message.mentions == ["me"]) + #expect(message.senderPeerID == peerID) + #expect(context.hapticMessageIDs == ["m1"]) + } + + @Test @MainActor + func didConnectAndDisconnect_manageSessionsStableIDsAndReadReceipts() async { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0xAB, count: 32) + context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") + + coordinator.didConnectToPeer(peerID) + await drainMainActorTasks() + #expect(context.isConnected) + #expect(context.registeredEphemeralSessions == [peerID]) + #expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey)) + #expect(context.flushedOutboxPeerIDs == [peerID]) + #expect(context.notifyUIChangedCount == 1) + + // Their messages' read receipts are un-marked on disconnect so READ + // acks can be re-sent after reconnect; our own messages are not. + context.privateChats[peerID] = [ + makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID), + makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID), + makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID), + ] + coordinator.didDisconnectFromPeer(peerID) + await drainMainActorTasks() + #expect(context.removedEphemeralSessions == [peerID]) + #expect(context.unmarkedReadReceiptBatches == [["theirs-1", "theirs-2"]]) + #expect(context.notifyUIChangedCount == 2) + } + + @Test @MainActor + func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0xCD, count: 32) + let stablePeerID = PeerID(hexData: noiseKey) + + // No cached stable ID: it must be derived from the Noise session key. + context.noiseSessionKeysByPeerID[peerID] = noiseKey + context.selectedPrivateChatPeer = peerID + context.unreadPrivateMessages = [peerID] + context.privateChats[peerID] = [ + makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID), + makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID), + ] + + coordinator.didDisconnectFromPeer(peerID) + await drainMainActorTasks() + + #expect(context.privateChats[peerID] == nil) + #expect(context.privateChats[stablePeerID]?.map(\.id) == ["m1", "mine"]) + // Sender IDs migrate to the stable peer ID, except our own. + #expect(context.privateChats[stablePeerID]?.first?.senderPeerID == stablePeerID) + #expect(context.privateChats[stablePeerID]?.last?.senderPeerID == context.myPeerID) + #expect(context.selectedPrivateChatPeer == stablePeerID) + #expect(context.unreadPrivateMessages == [stablePeerID]) + #expect(context.stablePeerIDCache[peerID] == stablePeerID) + } + + @Test @MainActor + func noisePayloads_driveDeliveryStatusAcksAndVerification() async { + let context = MockChatTransportEventContext() + let coordinator = ChatTransportEventCoordinator(context: context) + let peerID = PeerID(str: "99aabbccddeeff00") + let noiseKey = Data(repeating: 0x44, count: 32) + context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") + + // Inbound private message: decoded, handled, and delivery-acked. + let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there") + coordinator.didReceiveNoisePayload( + from: peerID, + type: .privateMessage, + payload: packet.encode() ?? Data(), + timestamp: Date() + ) + await drainMainActorTasks() + #expect(context.handledPrivateMessages.map(\.id) == ["pm-1"]) + #expect(context.handledPrivateMessages.first?.sender == "alice") + #expect(context.meshDeliveryAcks.count == 1) + #expect(context.meshDeliveryAcks.first?.messageID == "pm-1") + + // Delivered / read acks resolve the display name from the unified peer. + coordinator.didReceiveNoisePayload(from: peerID, type: .delivered, payload: Data("m-1".utf8), timestamp: Date()) + coordinator.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data("m-2".utf8), timestamp: Date()) + await drainMainActorTasks() + #expect(context.appliedDeliveryStatuses.count == 2) + #expect(context.appliedDeliveryStatuses[0].messageID == "m-1") + if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status { + #expect(to == "alice") + } else { + Issue.record("expected .delivered status") + } + if case .read(let by, _) = context.appliedDeliveryStatuses[1].status { + #expect(by == "alice") + } else { + Issue.record("expected .read status") + } + + // Verification payloads are forwarded untouched. + coordinator.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data([0x01]), timestamp: Date()) + coordinator.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data([0x02]), timestamp: Date()) + await drainMainActorTasks() + #expect(context.verifyChallengePayloads.count == 1) + #expect(context.verifyResponsePayloads.count == 1) + + // Blocked peers' private messages are dropped (no handling, no ack). + context.blockedPeers = [peerID] + coordinator.didReceiveNoisePayload( + from: peerID, + type: .privateMessage, + payload: packet.encode() ?? Data(), + timestamp: Date() + ) + await drainMainActorTasks() + #expect(context.handledPrivateMessages.count == 1) + #expect(context.meshDeliveryAcks.count == 1) + } +} diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift new file mode 100644 index 00000000..8dff14c3 --- /dev/null +++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift @@ -0,0 +1,284 @@ +// +// ChatVerificationCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatVerificationCoordinator` against a mock +// `ChatVerificationContext` โ€” proving the coordinator works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// Scope note: `handleVerifyResponsePayload` requires a real Ed25519 signature +// and posts via `NotificationService.shared`; it remains covered by the full +// view-model/integration tests. Challenge handling, QR kickoff, fingerprint +// verification, and verified-set loading are covered here +// (`VerificationService.shared` is only used for pure payload build/parse). +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatVerificationContext` proving that +/// `ChatVerificationCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatVerificationContext: ChatVerificationContext { + // Fingerprints & verification state + var fingerprintsByPeerID: [PeerID: String] = [:] + var verifiedFingerprints: Set = [] + var persistedFingerprints: Set = [] + private(set) var identityVerifiedCalls: [(fingerprint: String, verified: Bool)] = [] + private(set) var storedVerifiedCalls: [(fingerprint: String, verified: Bool)] = [] + private(set) var saveIdentityStateCount = 0 + + func getFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + func persistedVerifiedFingerprints() -> Set { persistedFingerprints } + + func setIdentityVerified(fingerprint: String, verified: Bool) { + identityVerifiedCalls.append((fingerprint, verified)) + } + + func setStoredVerified(_ fingerprint: String, verified: Bool) { + storedVerifiedCalls.append((fingerprint, verified)) + } + + func isVerifiedFingerprint(_ fingerprint: String) -> Bool { + verifiedFingerprints.contains(fingerprint) + } + + func saveIdentityState() { saveIdentityStateCount += 1 } + + // Encryption status + private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:] + private(set) var updatedEncryptionStatusPeers: [PeerID] = [] + private(set) var invalidatedEncryptionCachePeers: [PeerID?] = [] + private(set) var notifyUIChangedCount = 0 + + func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) { + encryptionStatuses[peerID] = status + } + + func updateEncryptionStatus(for peerID: PeerID) { + updatedEncryptionStatusPeers.append(peerID) + } + + func invalidateEncryptionCache(for peerID: PeerID?) { + invalidatedEncryptionCachePeers.append(peerID) + } + + func notifyUIChanged() { notifyUIChangedCount += 1 } + + // Peers + var unifiedPeers: [BitchatPeer] = [] + var unifiedFavorites: [BitchatPeer] = [] + private(set) var stablePeerIDCache: [PeerID: PeerID] = [:] + + func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { + unifiedPeers.first { $0.peerID == peerID } + } + + func unifiedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] } + func resolveNickname(for peerID: PeerID) -> String { "anon\(peerID.id.prefix(4))" } + func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? { stablePeerIDCache[shortPeerID] } + + func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID) { + stablePeerIDCache[shortPeerID] = stablePeerID + } + + // Noise sessions & verification transport + var myNoiseStaticKey = Data(repeating: 0x42, count: 32) + var establishedNoiseSessions: Set = [] + var noiseSessionKeysByPeerID: [PeerID: Data] = [:] + private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)? + private(set) var triggeredHandshakes: [PeerID] = [] + private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] + private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] + + func installNoiseSessionCallbacks( + onPeerAuthenticated: @escaping (PeerID, String) -> Void, + onHandshakeRequired: @escaping (PeerID) -> Void + ) { + installedCallbacks = (onPeerAuthenticated, onHandshakeRequired) + } + + func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { noiseSessionKeysByPeerID[peerID] } + func noiseStaticPublicKeyData() -> Data { myNoiseStaticKey } + func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool { + establishedNoiseSessions.contains(peerID) + } + func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) } + + func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { + sentChallenges.append((peerID, noiseKeyHex, nonceA)) + } + + func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { + sentResponses.append((peerID, noiseKeyHex, nonceA)) + } +} + +// MARK: - Helpers + +/// Builds the raw verify-challenge TLV as it arrives at the coordinator +/// (i.e. with the `NoisePayload` type byte already stripped). +private func makeVerifyChallengeTLV(noiseKeyHex: String, nonceA: Data) -> Data { + var tlv = Data() + tlv.append(0x01) + tlv.append(UInt8(noiseKeyHex.count)) + tlv.append(Data(noiseKeyHex.utf8)) + tlv.append(0x02) + tlv.append(UInt8(nonceA.count)) + tlv.append(nonceA) + return tlv +} + +private func makeVerificationQR(noiseKeyHex: String) -> VerificationService.VerificationQR { + VerificationService.VerificationQR( + v: 1, + noiseKeyHex: noiseKeyHex, + signKeyHex: "00" + String(repeating: "ab", count: 31), + npub: nil, + nickname: "alice", + ts: 0, + nonceB64: "", + sigHex: "" + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatVerificationCoordinator` against +/// `MockChatVerificationContext` with no `ChatViewModel`. +struct ChatVerificationCoordinatorContextTests { + + @Test @MainActor + func verifyAndUnverifyFingerprint_updateBothStoresAndStatus() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + + // Unknown fingerprint: nothing happens. + coordinator.verifyFingerprint(for: peerID) + #expect(context.identityVerifiedCalls.isEmpty) + + context.fingerprintsByPeerID[peerID] = "fp" + coordinator.verifyFingerprint(for: peerID) + coordinator.unverifyFingerprint(for: peerID) + + #expect(context.identityVerifiedCalls.map(\.fingerprint) == ["fp", "fp"]) + #expect(context.identityVerifiedCalls.map(\.verified) == [true, false]) + #expect(context.storedVerifiedCalls.map(\.verified) == [true, false]) + #expect(context.saveIdentityStateCount == 2) + #expect(context.updatedEncryptionStatusPeers == [peerID, peerID]) + } + + @Test @MainActor + func beginQRVerification_sendsChallengeOrTriggersHandshake() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + let noiseKey = Data(repeating: 0xCD, count: 32) + let peerID = PeerID(str: "1122334455667788") + let qr = makeVerificationQR(noiseKeyHex: noiseKey.hexEncodedString()) + + // No matching peer -> not started. + #expect(!coordinator.beginQRVerification(with: qr)) + + // Matching peer without an established session -> handshake first. + context.unifiedPeers = [BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")] + #expect(coordinator.beginQRVerification(with: qr)) + #expect(context.triggeredHandshakes == [peerID]) + #expect(context.sentChallenges.isEmpty) + + // Already pending -> short-circuits without re-triggering. + #expect(coordinator.beginQRVerification(with: qr)) + #expect(context.triggeredHandshakes == [peerID]) + + // Fresh coordinator with an established session -> immediate challenge. + let context2 = MockChatVerificationContext() + context2.unifiedPeers = [BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")] + context2.establishedNoiseSessions = [peerID] + let coordinator2 = ChatVerificationCoordinator(context: context2) + #expect(coordinator2.beginQRVerification(with: qr)) + #expect(context2.sentChallenges.count == 1) + #expect(context2.sentChallenges.first?.noiseKeyHex == qr.noiseKeyHex) + #expect(context2.triggeredHandshakes.isEmpty) + } + + @Test @MainActor + func handleVerifyChallengePayload_respondsOncePerNonceForOurKeyOnly() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let myHex = context.myNoiseStaticKey.hexEncodedString() + let nonce = Data(repeating: 0x07, count: 16) + let payload = makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: nonce) + + coordinator.handleVerifyChallengePayload(from: peerID, payload: payload) + #expect(context.sentResponses.count == 1) + #expect(context.sentResponses.first?.noiseKeyHex.lowercased() == myHex) + #expect(context.sentResponses.first?.nonceA == nonce) + + // Same nonce again: deduplicated, no second response. + coordinator.handleVerifyChallengePayload(from: peerID, payload: payload) + #expect(context.sentResponses.count == 1) + + // A challenge for someone else's key is ignored. + let otherHex = Data(repeating: 0x99, count: 32).hexEncodedString() + let otherPayload = makeVerifyChallengeTLV( + noiseKeyHex: otherHex, + nonceA: Data(repeating: 0x08, count: 16) + ) + coordinator.handleVerifyChallengePayload(from: peerID, payload: otherPayload) + #expect(context.sentResponses.count == 1) + } + + @Test @MainActor + func loadVerifiedFingerprints_syncsPersistedSetAndRefreshesUI() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + context.persistedFingerprints = ["fp1", "fp2"] + + coordinator.loadVerifiedFingerprints() + + #expect(context.verifiedFingerprints == ["fp1", "fp2"]) + #expect(context.invalidatedEncryptionCachePeers == [nil]) + #expect(context.notifyUIChangedCount == 1) + } + + @Test @MainActor + func installedNoiseCallbacks_publishStatusAndStableIDs() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let noiseKey = Data(repeating: 0x33, count: 32) + context.noiseSessionKeysByPeerID[peerID] = noiseKey + context.verifiedFingerprints = ["fp-verified"] + + coordinator.setupNoiseCallbacks() + let callbacks = try? #require(context.installedCallbacks) + + // Authenticated with a verified fingerprint -> verified status and a + // cached stable peer ID derived from the session key. + callbacks?.onPeerAuthenticated(peerID, "fp-verified") + await waitForMainQueue() + #expect(context.encryptionStatuses[peerID] == .noiseVerified) + #expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey)) + #expect(context.invalidatedEncryptionCachePeers.contains(peerID)) + + // Handshake required -> handshaking status. + callbacks?.onHandshakeRequired(peerID) + await waitForMainQueue() + #expect(context.encryptionStatuses[peerID] == .noiseHandshaking) + } +} + +/// The installed callbacks hop through `DispatchQueue.main.async`; tests must +/// let that queue drain before asserting. +@MainActor +private func waitForMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } +} From 6091ee83ad1dc492996bf993538557a9ccae4a8d Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Jun 2026 22:13:09 +0200 Subject: [PATCH 5/9] Finish coordinator migration: zero ChatViewModel back-references remain ChatPeerListCoordinator, ChatComposerCoordinator, ChatOutgoingCoordinator, and GeoChannelCoordinator complete the migration; every coordinator now depends on a narrow @MainActor context protocol. GeoChannelCoordinator's three injected closures collapse into a weak context. New intent op recordPublicActivity(forChannelKey:) keeps lastPublicActivityAt single-writer. 15 new mock-context tests; flaky-poll deadline in the gift-wrap dedup test raised for parallel load. Co-Authored-By: Claude Fable 5 --- .../ViewModels/ChatComposerCoordinator.swift | 115 ++++++--- .../ViewModels/ChatOutgoingCoordinator.swift | 122 +++++++--- .../ViewModels/ChatPeerListCoordinator.swift | 100 +++++--- bitchat/ViewModels/ChatViewModel.swift | 6 +- .../ChatViewModelBootstrapper.swift | 10 +- .../ViewModels/GeoChannelCoordinator.swift | 41 ++-- .../ChatComposerCoordinatorContextTests.swift | 157 +++++++++++++ .../ChatOutgoingCoordinatorContextTests.swift | 221 ++++++++++++++++++ .../ChatPeerListCoordinatorContextTests.swift | 165 +++++++++++++ .../ChatViewModelExtensionsTests.swift | 4 +- .../GeoChannelCoordinatorContextTests.swift | 196 ++++++++++++++++ 11 files changed, 1023 insertions(+), 114 deletions(-) create mode 100644 bitchatTests/ChatComposerCoordinatorContextTests.swift create mode 100644 bitchatTests/ChatOutgoingCoordinatorContextTests.swift create mode 100644 bitchatTests/ChatPeerListCoordinatorContextTests.swift create mode 100644 bitchatTests/GeoChannelCoordinatorContextTests.swift diff --git a/bitchat/ViewModels/ChatComposerCoordinator.swift b/bitchat/ViewModels/ChatComposerCoordinator.swift index 133e3f1c..c554a9e3 100644 --- a/bitchat/ViewModels/ChatComposerCoordinator.swift +++ b/bitchat/ViewModels/ChatComposerCoordinator.swift @@ -1,44 +1,105 @@ import BitFoundation import Foundation +/// The narrow surface `ChatComposerCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatComposerCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatComposerContext: AnyObject { + // MARK: Autocomplete UI state + var autocompleteSuggestions: [String] { get set } + var autocompleteRange: NSRange? { get set } + var showAutocomplete: Bool { get set } + var selectedAutocompleteIndex: Int { get set } + /// Computes mention suggestions for the text up to the cursor. + func autocompleteQuery( + for text: String, + peers: [String], + cursorPosition: Int + ) -> (suggestions: [String], range: NSRange?) + /// Replaces the matched range in `text` with the chosen suggestion. + func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String + + // MARK: Identity & channel state + var nickname: String { get } + var myPeerID: PeerID { get } + var activeChannel: ChannelID { get } + /// The transport's own nickname (excluded from autocomplete candidates). + var meshNickname: String { get } + func meshPeerNicknames() -> [PeerID: String] + + // MARK: Geohash identity (shared with the other contexts) + var geoNicknames: [String: String] { get } + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity +} + +extension ChatViewModel: ChatComposerContext { + // `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`, + // `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`, + // `geoNicknames`, `meshPeerNicknames()`, and + // `deriveNostrIdentity(forGeohash:)` are shared requirements with the + // other contexts or satisfied by existing `ChatViewModel` members. The + // members below flatten nested service accesses into intent-named calls. + + func autocompleteQuery( + for text: String, + peers: [String], + cursorPosition: Int + ) -> (suggestions: [String], range: NSRange?) { + autocompleteService.getSuggestions(for: text, peers: peers, cursorPosition: cursorPosition) + } + + func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String { + autocompleteService.applySuggestion(suggestion, to: text, range: range) + } + + var meshNickname: String { + meshService.myNickname + } +} + @MainActor final class ChatComposerCoordinator { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatComposerContext - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatComposerContext) { + self.context = context } func updateAutocomplete(for text: String, cursorPosition: Int) { let peerCandidates = autocompleteCandidates() - let (suggestions, range) = viewModel.autocompleteService.getSuggestions( + let (suggestions, range) = context.autocompleteQuery( for: text, peers: peerCandidates, cursorPosition: cursorPosition ) if !suggestions.isEmpty { - viewModel.autocompleteSuggestions = suggestions - viewModel.autocompleteRange = range - viewModel.showAutocomplete = true - viewModel.selectedAutocompleteIndex = 0 + context.autocompleteSuggestions = suggestions + context.autocompleteRange = range + context.showAutocomplete = true + context.selectedAutocompleteIndex = 0 } else { - viewModel.autocompleteSuggestions = [] - viewModel.autocompleteRange = nil - viewModel.showAutocomplete = false - viewModel.selectedAutocompleteIndex = 0 + context.autocompleteSuggestions = [] + context.autocompleteRange = nil + context.showAutocomplete = false + context.selectedAutocompleteIndex = 0 } } func completeNickname(_ nickname: String, in text: inout String) -> Int { - guard let range = viewModel.autocompleteRange else { return text.count } + guard let range = context.autocompleteRange else { return text.count } - text = viewModel.autocompleteService.applySuggestion(nickname, to: text, range: range) + text = context.applyAutocompleteSuggestion(nickname, to: text, range: range) - viewModel.showAutocomplete = false - viewModel.autocompleteSuggestions = [] - viewModel.autocompleteRange = nil - viewModel.selectedAutocompleteIndex = 0 + context.showAutocomplete = false + context.autocompleteSuggestions = [] + context.autocompleteRange = nil + context.selectedAutocompleteIndex = 0 return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2) } @@ -52,10 +113,10 @@ final class ChatComposerCoordinator { range: NSRange(location: 0, length: nsContent.length) ) - let peerNicknames = viewModel.meshService.getPeerNicknames() + let peerNicknames = context.meshPeerNicknames() var validTokens = Set(peerNicknames.values) - validTokens.insert(viewModel.nickname) - validTokens.insert(viewModel.nickname + "#" + String(viewModel.meshService.myPeerID.id.prefix(4))) + validTokens.insert(context.nickname) + validTokens.insert(context.nickname + "#" + String(context.myPeerID.id.prefix(4))) var mentions: [String] = [] for match in matches { @@ -72,18 +133,18 @@ final class ChatComposerCoordinator { private extension ChatComposerCoordinator { func autocompleteCandidates() -> [String] { - switch viewModel.activeChannel { + switch context.activeChannel { case .mesh: - let values = viewModel.meshService.getPeerNicknames().values - return Array(values.filter { $0 != viewModel.meshService.myNickname }) + let values = context.meshPeerNicknames().values + return Array(values.filter { $0 != context.meshNickname }) case .location(let channel): var tokens = Set() - for (pubkey, nick) in viewModel.geoNicknames { + for (pubkey, nick) in context.geoNicknames { tokens.insert("\(nick)#\(pubkey.suffix(4))") } - if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { - let myToken = viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4)) + if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { + let myToken = context.nickname + "#" + String(identity.publicKeyHex.suffix(4)) tokens.remove(myToken) } return Array(tokens) diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift index 555f9fc5..5cf5d87b 100644 --- a/bitchat/ViewModels/ChatOutgoingCoordinator.swift +++ b/bitchat/ViewModels/ChatOutgoingCoordinator.swift @@ -2,34 +2,96 @@ import BitFoundation import BitLogger import Foundation +/// The narrow surface `ChatOutgoingCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatOutgoingCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatOutgoingContext: AnyObject { + // MARK: Identity & channel state + var nickname: String { get } + var myPeerID: PeerID { get } + var activeChannel: ChannelID { get } + var selectedPrivateChatPeer: PeerID? { get } + var isTeleported: Bool { get } + + // MARK: Commands & private messages + func handleCommand(_ command: String) + func updatePrivateChatPeerIfNeeded() + func sendPrivateMessage(_ content: String, to peerID: PeerID) + + // MARK: Public timeline (local echo) + func parseMentions(from content: String) -> [String] + func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) + func refreshVisibleMessages(from channel: ChannelID?) + func trimMessagesIfNeeded() + func addSystemMessage(_ content: String) + + // MARK: Content dedup + func normalizedContentKey(_ content: String) -> String + func recordContentKey(_ key: String, timestamp: Date) + + // MARK: Outbound routing + /// Stamps "now" as the channel's last public activity (background nudges). + /// (Single mutation path for the owner's `lastPublicActivityAt`; this + /// coordinator never reads it.) + func recordPublicActivity(forChannelKey key: String) + func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) + func sendGeohash(context: ChatViewModel.GeoOutgoingContext) + + // MARK: Geohash identity (shared with the other contexts) + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity +} + +extension ChatViewModel: ChatOutgoingContext { + // `nickname`, `myPeerID`, `activeChannel`, `selectedPrivateChatPeer`, + // `isTeleported`, `handleCommand(_:)`, `updatePrivateChatPeerIfNeeded()`, + // `sendPrivateMessage(_:to:)`, `parseMentions(from:)`, + // `appendTimelineMessage(_:to:)`, `refreshVisibleMessages(from:)`, + // `trimMessagesIfNeeded()`, `addSystemMessage(_:)`, + // `normalizedContentKey(_:)`, `recordContentKey(_:timestamp:)`, + // `sendMeshMessage(_:mentions:messageID:timestamp:)`, + // `sendGeohash(context:)`, and `deriveNostrIdentity(forGeohash:)` are + // shared requirements with the other contexts or satisfied by existing + // `ChatViewModel` members. The single-writer intent op below lives next to + // its backing state's owner. + + func recordPublicActivity(forChannelKey key: String) { + lastPublicActivityAt[key] = Date() + } +} + @MainActor final class ChatOutgoingCoordinator { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatOutgoingContext - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatOutgoingContext) { + self.context = context } func sendMessage(_ content: String) { guard let trimmed = content.trimmedOrNilIfEmpty else { return } if content.hasPrefix("/") { - Task { @MainActor [weak viewModel] in - viewModel?.handleCommand(content) + Task { @MainActor [weak context = self.context] in + context?.handleCommand(content) } return } - if viewModel.selectedPrivateChatPeer != nil { - viewModel.updatePrivateChatPeerIfNeeded() + if context.selectedPrivateChatPeer != nil { + context.updatePrivateChatPeerIfNeeded() - if let selectedPeer = viewModel.selectedPrivateChatPeer { - viewModel.sendPrivateMessage(content, to: selectedPeer) + if let selectedPeer = context.selectedPrivateChatPeer { + context.sendPrivateMessage(content, to: selectedPeer) } return } - let mentions = viewModel.parseMentions(from: content) + let mentions = context.parseMentions(from: content) let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions) guard let preparedMessage else { return } @@ -51,28 +113,28 @@ private extension ChatOutgoingCoordinator { mentions: [String] ) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? { var geoContext: ChatViewModel.GeoOutgoingContext? - var displaySender = viewModel.nickname - var localSenderPeerID = viewModel.meshService.myPeerID + var displaySender = context.nickname + var localSenderPeerID = context.myPeerID var messageID: String? var messageTimestamp = Date() - switch viewModel.activeChannel { + switch context.activeChannel { case .mesh: break case .location(let channel): do { - let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) + let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash) let suffix = String(identity.publicKeyHex.suffix(4)) - displaySender = viewModel.nickname + "#" + suffix + displaySender = context.nickname + "#" + suffix localSenderPeerID = PeerID(nostr: identity.publicKeyHex) - let teleported = viewModel.locationManager.teleported + let teleported = context.isTeleported let event = try NostrProtocol.createEphemeralGeohashEvent( content: trimmed, geohash: channel.geohash, senderIdentity: identity, - nickname: viewModel.nickname, + nickname: context.nickname, teleported: teleported ) @@ -86,7 +148,7 @@ private extension ChatOutgoingCoordinator { ) } catch { SecureLogger.error("โŒ Failed to prepare geohash message: \(error)", category: .session) - viewModel.addSystemMessage( + context.addSystemMessage( String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") ) return nil @@ -107,12 +169,12 @@ private extension ChatOutgoingCoordinator { } func appendLocalEcho(_ message: BitchatMessage) { - viewModel.timelineStore.append(message, to: viewModel.activeChannel) - viewModel.refreshVisibleMessages(from: viewModel.activeChannel) + context.appendTimelineMessage(message, to: context.activeChannel) + context.refreshVisibleMessages(from: context.activeChannel) - let contentKey = viewModel.deduplicationService.normalizedContentKey(message.content) - viewModel.deduplicationService.recordContentKey(contentKey, timestamp: message.timestamp) - viewModel.trimMessagesIfNeeded() + let contentKey = context.normalizedContentKey(message.content) + context.recordContentKey(contentKey, timestamp: message.timestamp) + context.trimMessagesIfNeeded() } func routePublicMessage( @@ -122,10 +184,10 @@ private extension ChatOutgoingCoordinator { messageID: String, timestamp: Date ) { - switch viewModel.activeChannel { + switch context.activeChannel { case .mesh: - viewModel.lastPublicActivityAt["mesh"] = Date() - viewModel.meshService.sendMessage( + context.recordPublicActivity(forChannelKey: "mesh") + context.sendMeshMessage( originalContent, mentions: mentions, messageID: messageID, @@ -133,18 +195,18 @@ private extension ChatOutgoingCoordinator { ) case .location(let channel): - viewModel.lastPublicActivityAt["geo:\(channel.geohash)"] = Date() + context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)") guard let geoContext, geoContext.channel.geohash == channel.geohash else { SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session) - viewModel.addSystemMessage( + context.addSystemMessage( String(localized: "system.location.send_failed", comment: "System message when a location channel send fails") ) return } - Task { @MainActor [weak viewModel] in - viewModel?.sendGeohash(context: geoContext) + Task { @MainActor [weak context = self.context] in + context?.sendGeohash(context: geoContext) } } } diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index 80a24f2b..f2fff0af 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -2,16 +2,64 @@ import BitFoundation import BitLogger import Foundation +/// The narrow surface `ChatPeerListCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of holding an `unowned` back-ref +/// to the whole `ChatViewModel`. This keeps the coordinator independently +/// testable (see `ChatPeerListCoordinatorContextTests`) and makes its true +/// dependencies explicit. +@MainActor +protocol ChatPeerListContext: AnyObject { + // MARK: Connection & chat state + var isConnected: Bool { get set } + var privateChats: [PeerID: [BitchatMessage]] { get } + var unreadPrivateMessages: Set { get set } + var hasTrackedPrivateChatSelection: Bool { get } + func updatePrivateChatPeerIfNeeded() + func cleanupOldReadReceipts() + + // MARK: Peers & sessions + var unifiedPeers: [BitchatPeer] { get } + func isPeerConnected(_ peerID: PeerID) -> Bool + func isPeerReachable(_ peerID: PeerID) -> Bool + /// Number of mesh peers currently connected or reachable, from the + /// transport's live peer snapshots. + func activeMeshPeerCount() -> Int + func registerEphemeralSession(peerID: PeerID) + func updateEncryptionStatusForPeers() +} + +extension ChatViewModel: ChatPeerListContext { + // `isConnected`, `privateChats`, `unreadPrivateMessages`, + // `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`, + // `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`, + // `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and + // `updateEncryptionStatusForPeers()` are shared requirements with the + // other contexts or satisfied by existing `ChatViewModel` members. The + // member below flattens the nested transport access into an intent-named + // call. + + func activeMeshPeerCount() -> Int { + meshService + .currentPeerSnapshots() + .filter { snapshot in + snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID) + } + .count + } +} + final class ChatPeerListCoordinator: @unchecked Sendable { - private unowned let viewModel: ChatViewModel + private unowned let context: any ChatPeerListContext private var recentlySeenPeers: Set = [] private var lastNetworkNotificationTime = Date.distantPast private var networkResetTimer: Timer? private var networkEmptyTimer: Timer? private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + init(context: any ChatPeerListContext) { + self.context = context } deinit { @@ -29,23 +77,23 @@ final class ChatPeerListCoordinator: @unchecked Sendable { private extension ChatPeerListCoordinator { @MainActor func handlePeerListUpdate(_ peers: [PeerID]) { - viewModel.isConnected = !peers.isEmpty + context.isConnected = !peers.isEmpty cleanupStaleUnreadPeerIDs() let meshPeers = peers.filter { peerID in - viewModel.meshService.isPeerConnected(peerID) || viewModel.meshService.isPeerReachable(peerID) + context.isPeerConnected(peerID) || context.isPeerReachable(peerID) } handleNetworkAvailability(meshPeers) for peerID in peers { - viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none) + context.registerEphemeralSession(peerID: peerID) } - viewModel.updateEncryptionStatusForPeers() + context.updateEncryptionStatusForPeers() - if viewModel.hasTrackedPrivateChatSelection { - viewModel.updatePrivateChatPeerIfNeeded() + if context.hasTrackedPrivateChatSelection { + context.updatePrivateChatPeerIfNeeded() } } @@ -79,34 +127,34 @@ private extension ChatPeerListCoordinator { @MainActor func cleanupStaleUnreadPeerIDs() { - let currentPeerIDs = Set(viewModel.unifiedPeerService.peers.map(\.peerID)) - let staleIDs = viewModel.unreadPrivateMessages.subtracting(currentPeerIDs) + let currentPeerIDs = Set(context.unifiedPeers.map(\.peerID)) + let staleIDs = context.unreadPrivateMessages.subtracting(currentPeerIDs) guard !staleIDs.isEmpty else { - viewModel.cleanupOldReadReceipts() + context.cleanupOldReadReceipts() return } var idsToRemove: [PeerID] = [] for staleID in staleIDs { - if staleID.isGeoDM, let messages = viewModel.privateChats[staleID], !messages.isEmpty { + if staleID.isGeoDM, let messages = context.privateChats[staleID], !messages.isEmpty { continue } - if staleID.isNoiseKeyHex, let messages = viewModel.privateChats[staleID], !messages.isEmpty { + if staleID.isNoiseKeyHex, let messages = context.privateChats[staleID], !messages.isEmpty { continue } idsToRemove.append(staleID) - viewModel.unreadPrivateMessages.remove(staleID) + context.unreadPrivateMessages.remove(staleID) } if !idsToRemove.isEmpty { SecureLogger.debug("๐Ÿงน Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session) } - viewModel.cleanupOldReadReceipts() + context.cleanupOldReadReceipts() } @MainActor @@ -121,18 +169,14 @@ private extension ChatPeerListCoordinator { @MainActor func handleNetworkResetTimerFired() { - let activeMeshPeers = viewModel.meshService - .currentPeerSnapshots() - .filter { snapshot in - snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID) - } + let activeMeshPeerCount = context.activeMeshPeerCount() - if activeMeshPeers.isEmpty { + if activeMeshPeerCount == 0 { recentlySeenPeers.removeAll() SecureLogger.debug("โฑ๏ธ Network notification window reset after quiet period", category: .session) } else { SecureLogger.debug( - "โฑ๏ธ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers", + "โฑ๏ธ Skipped network notification reset; still seeing \(activeMeshPeerCount) mesh peers", category: .session ) } @@ -165,18 +209,14 @@ private extension ChatPeerListCoordinator { @MainActor func handleNetworkEmptyTimerFired() { - let activeMeshPeers = viewModel.meshService - .currentPeerSnapshots() - .filter { snapshot in - snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID) - } + let activeMeshPeerCount = context.activeMeshPeerCount() - if activeMeshPeers.isEmpty { + if activeMeshPeerCount == 0 { recentlySeenPeers.removeAll() SecureLogger.debug("โณ Mesh empty โ€” notification state reset after confirmation", category: .session) } else { SecureLogger.debug( - "โณ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again", + "โณ Mesh empty timer cancelled; \(activeMeshPeerCount) mesh peers detected again", category: .session ) } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index e5e3baa6..4318ce1e 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -146,14 +146,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele let unifiedPeerService: UnifiedPeerService let autocompleteService: AutocompleteService let deduplicationService: MessageDeduplicationService // internal for test access - private lazy var outgoingCoordinator = ChatOutgoingCoordinator(viewModel: self) + private lazy var outgoingCoordinator = ChatOutgoingCoordinator(context: self) private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(context: self) private lazy var transportEventCoordinator = ChatTransportEventCoordinator(context: self) - private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self) + private lazy var peerListCoordinator = ChatPeerListCoordinator(context: self) private lazy var messageFormatter = ChatMessageFormatter(viewModel: self) lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self) lazy var deliveryCoordinator = ChatDeliveryCoordinator(context: self) - lazy var composerCoordinator = ChatComposerCoordinator(viewModel: self) + lazy var composerCoordinator = ChatComposerCoordinator(context: self) lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self) lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self) lazy var nostrCoordinator = ChatNostrCoordinator(context: self) diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index fa23bb52..3fec30e0 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -217,15 +217,7 @@ private extension ChatViewModelBootstrapper { func configureGeoChannels() { viewModel.geoChannelCoordinator = GeoChannelCoordinator( locationManager: viewModel.locationManager, - onChannelSwitch: { [weak viewModel] channel in - viewModel?.switchLocationChannel(to: channel) - }, - beginSampling: { [weak viewModel] geohashes in - viewModel?.beginGeohashSampling(for: geohashes) - }, - endSampling: { [weak viewModel] in - viewModel?.endGeohashSampling() - } + context: viewModel ) } diff --git a/bitchat/ViewModels/GeoChannelCoordinator.swift b/bitchat/ViewModels/GeoChannelCoordinator.swift index 60879334..51af663a 100644 --- a/bitchat/ViewModels/GeoChannelCoordinator.swift +++ b/bitchat/ViewModels/GeoChannelCoordinator.swift @@ -9,15 +9,32 @@ import Combine import Foundation import Tor +/// The narrow surface `GeoChannelCoordinator` needs from its owner. +/// +/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the +/// minimal context it actually uses instead of capturing `ChatViewModel` in +/// per-callback closures. This keeps the coordinator independently testable +/// (see `GeoChannelCoordinatorContextTests`) and makes its true dependencies +/// explicit. Held `weak` โ€” the owner retains the coordinator, and every +/// callback was previously a `[weak viewModel]` capture. +@MainActor +protocol GeoChannelContext: AnyObject { + func switchLocationChannel(to channel: ChannelID) + func beginGeohashSampling(for geohashes: [String]) + func endGeohashSampling() +} + +// `switchLocationChannel(to:)`, `beginGeohashSampling(for:)`, and +// `endGeohashSampling()` are satisfied by existing `ChatViewModel` members. +extension ChatViewModel: GeoChannelContext {} + @MainActor final class GeoChannelCoordinator { private let locationManager: LocationChannelManager private let bookmarksStore: GeohashBookmarksStore private let torManager: TorManager - private let onChannelSwitch: (ChannelID) -> Void - private let beginSampling: ([String]) -> Void - private let endSampling: () -> Void + private weak var context: (any GeoChannelContext)? private var cancellables = Set() private var regionalGeohashes: [String] = [] @@ -27,16 +44,12 @@ final class GeoChannelCoordinator { locationManager: LocationChannelManager? = nil, bookmarksStore: GeohashBookmarksStore? = nil, torManager: TorManager? = nil, - onChannelSwitch: @escaping (ChannelID) -> Void, - beginSampling: @escaping ([String]) -> Void, - endSampling: @escaping () -> Void + context: any GeoChannelContext ) { self.locationManager = locationManager ?? Self.defaultLocationManager() self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared self.torManager = torManager ?? Self.defaultTorManager() - self.onChannelSwitch = onChannelSwitch - self.beginSampling = beginSampling - self.endSampling = endSampling + self.context = context start() } @@ -50,7 +63,7 @@ final class GeoChannelCoordinator { .sink { [weak self] channel in guard let self else { return } Task { @MainActor in - self.onChannelSwitch(channel) + self.context?.switchLocationChannel(to: channel) } } .store(in: &cancellables) @@ -84,7 +97,7 @@ final class GeoChannelCoordinator { .store(in: &cancellables) Task { @MainActor in - self.onChannelSwitch(self.locationManager.selectedChannel) + self.context?.switchLocationChannel(to: self.locationManager.selectedChannel) } updateSampling() } @@ -93,13 +106,13 @@ final class GeoChannelCoordinator { let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes)) Task { @MainActor in guard !union.isEmpty else { - endSampling() + context?.endGeohashSampling() return } if torManager.isForeground() { - beginSampling(union) + context?.beginGeohashSampling(for: union) } else { - endSampling() + context?.endGeohashSampling() } } } diff --git a/bitchatTests/ChatComposerCoordinatorContextTests.swift b/bitchatTests/ChatComposerCoordinatorContextTests.swift new file mode 100644 index 00000000..49891a1e --- /dev/null +++ b/bitchatTests/ChatComposerCoordinatorContextTests.swift @@ -0,0 +1,157 @@ +// +// ChatComposerCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatComposerCoordinator` against a mock `ChatComposerContext` โ€” +// proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` exemplar. +// +// Scope note: mention parsing uses the shared, precompiled +// `ChatViewModel.Patterns.mention` regex (a static, stateless singleton); +// everything else flows through the mock context. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatComposerContext` proving that +/// `ChatComposerCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatComposerContext: ChatComposerContext { + // Autocomplete UI state + var autocompleteSuggestions: [String] = [] + var autocompleteRange: NSRange? + var showAutocomplete = false + var selectedAutocompleteIndex = -1 + var queryResult: (suggestions: [String], range: NSRange?) = ([], nil) + private(set) var queriedPeerCandidates: [[String]] = [] + private(set) var appliedSuggestions: [(suggestion: String, text: String, range: NSRange)] = [] + + func autocompleteQuery( + for text: String, + peers: [String], + cursorPosition: Int + ) -> (suggestions: [String], range: NSRange?) { + queriedPeerCandidates.append(peers.sorted()) + return queryResult + } + + func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String { + appliedSuggestions.append((suggestion, text, range)) + guard let textRange = Range(range, in: text) else { return text } + return text.replacingCharacters(in: textRange, with: suggestion) + } + + // Identity & channel state + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var activeChannel: ChannelID = .mesh + var meshNickname = "me" + var meshNicknamesByPeerID: [PeerID: String] = [:] + + func meshPeerNicknames() -> [PeerID: String] { meshNicknamesByPeerID } + + // Geohash identity + var geoNicknames: [String: String] = [:] + static let dummyIdentity = NostrIdentity( + privateKey: Data(repeating: 0x11, count: 32), + publicKey: Data(repeating: 0x22, count: 32), + npub: "npub1mock", + createdAt: Date(timeIntervalSince1970: 0) + ) + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { + Self.dummyIdentity + } +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatComposerCoordinator` against `MockChatComposerContext` with +/// no `ChatViewModel`. +struct ChatComposerCoordinatorContextTests { + + @Test @MainActor + func updateAutocomplete_onMesh_excludesOwnNicknameAndPublishesSuggestions() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + context.meshNicknamesByPeerID = [ + PeerID(str: "1111111111111111"): "alice", + PeerID(str: "2222222222222222"): "bob", + PeerID(str: "3333333333333333"): "me", + ] + + // Matching query: suggestions and range are published, index resets. + context.queryResult = (["@alice"], NSRange(location: 0, length: 3)) + coordinator.updateAutocomplete(for: "@al", cursorPosition: 3) + #expect(context.queriedPeerCandidates == [["alice", "bob"]]) + #expect(context.autocompleteSuggestions == ["@alice"]) + #expect(context.autocompleteRange == NSRange(location: 0, length: 3)) + #expect(context.showAutocomplete) + #expect(context.selectedAutocompleteIndex == 0) + + // No match: all autocomplete state is cleared. + context.queryResult = ([], nil) + context.selectedAutocompleteIndex = 3 + coordinator.updateAutocomplete(for: "plain text", cursorPosition: 5) + #expect(context.autocompleteSuggestions.isEmpty) + #expect(context.autocompleteRange == nil) + #expect(!context.showAutocomplete) + #expect(context.selectedAutocompleteIndex == 0) + } + + @Test @MainActor + func updateAutocomplete_onLocationChannel_buildsGeoTokensWithoutOwnToken() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq")) + context.geoNicknames = [ + "aaaabbbbccccdddd": "carol", + // Own token (nickname#last-4-of-pubkey) must be removed; the dummy + // identity's public key hex ends in "2222". + "ffffeeeeddddcccc2222": "me", + ] + + coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3) + #expect(context.queriedPeerCandidates == [["carol#dddd"]]) + } + + @Test @MainActor + func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + + // Without an active range the text is untouched. + var text = "hello @al" + #expect(coordinator.completeNickname("@alice", in: &text) == text.count) + #expect(context.appliedSuggestions.isEmpty) + + // With a range the suggestion is applied and state cleared. + context.autocompleteRange = NSRange(location: 6, length: 3) + context.autocompleteSuggestions = ["@alice"] + context.showAutocomplete = true + let cursor = coordinator.completeNickname("@alice", in: &text) + #expect(text == "hello @alice") + #expect(cursor == 6 + "@alice".count + 1) + #expect(!context.showAutocomplete) + #expect(context.autocompleteSuggestions.isEmpty) + #expect(context.autocompleteRange == nil) + #expect(context.selectedAutocompleteIndex == 0) + } + + @Test @MainActor + func parseMentions_acceptsKnownPeersOwnNicknameAndHashSuffix() { + let context = MockChatComposerContext() + let coordinator = ChatComposerCoordinator(context: context) + context.meshNicknamesByPeerID = [PeerID(str: "1111111111111111"): "alice"] + + let mentions = coordinator.parseMentions( + from: "hi @alice and @me and @me#0011 but not @stranger" + ) + #expect(Set(mentions) == ["alice", "me", "me#0011"]) + } +} diff --git a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift new file mode 100644 index 00000000..fcb61d8f --- /dev/null +++ b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift @@ -0,0 +1,221 @@ +// +// ChatOutgoingCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatOutgoingCoordinator` against a mock `ChatOutgoingContext` โ€” +// proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` exemplar. +// +// Scope note: the geohash path builds and signs a real Nostr event via +// `NostrProtocol.createEphemeralGeohashEvent` (pure crypto, no shared state); +// everything else flows through the mock context. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatOutgoingContext` proving that +/// `ChatOutgoingCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatOutgoingContext: ChatOutgoingContext { + // Identity & channel state + var nickname = "me" + var myPeerID = PeerID(str: "0011223344556677") + var activeChannel: ChannelID = .mesh + var selectedPrivateChatPeer: PeerID? + var isTeleported = false + + // Commands & private messages + var selectedPeerAfterUpdate: PeerID?? + private(set) var handledCommands: [String] = [] + private(set) var updatePrivateChatPeerIfNeededCount = 0 + private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = [] + + func handleCommand(_ command: String) { handledCommands.append(command) } + + func updatePrivateChatPeerIfNeeded() { + updatePrivateChatPeerIfNeededCount += 1 + if let selectedPeerAfterUpdate { + selectedPrivateChatPeer = selectedPeerAfterUpdate + } + } + + func sendPrivateMessage(_ content: String, to peerID: PeerID) { + sentPrivateMessages.append((content, peerID)) + } + + // Public timeline (local echo) + private(set) var appendedTimelineMessages: [(message: BitchatMessage, channel: ChannelID)] = [] + private(set) var refreshedChannels: [ChannelID?] = [] + private(set) var trimMessagesIfNeededCount = 0 + private(set) var systemMessages: [String] = [] + + func parseMentions(from content: String) -> [String] { + content.contains("@bob") ? ["bob"] : [] + } + + func appendTimelineMessage(_ message: BitchatMessage, to channel: ChannelID) { + appendedTimelineMessages.append((message, channel)) + } + + func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) } + func trimMessagesIfNeeded() { trimMessagesIfNeededCount += 1 } + func addSystemMessage(_ content: String) { systemMessages.append(content) } + + // Content dedup + private(set) var recordedContentKeys: [(key: String, timestamp: Date)] = [] + + func normalizedContentKey(_ content: String) -> String { "key:\(content)" } + func recordContentKey(_ key: String, timestamp: Date) { + recordedContentKeys.append((key, timestamp)) + } + + // Outbound routing + private(set) var recordedActivityKeys: [String] = [] + private(set) var sentMeshMessages: [(content: String, mentions: [String], messageID: String, timestamp: Date)] = [] + private(set) var sentGeohashContexts: [ChatViewModel.GeoOutgoingContext] = [] + + func recordPublicActivity(forChannelKey key: String) { recordedActivityKeys.append(key) } + + func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { + sentMeshMessages.append((content, mentions, messageID, timestamp)) + } + + func sendGeohash(context: ChatViewModel.GeoOutgoingContext) { + sentGeohashContexts.append(context) + } + + // Geohash identity + struct IdentityUnavailable: Error {} + var deriveNostrIdentityError: Error? + static let dummyIdentity = NostrIdentity( + privateKey: Data(repeating: 0x11, count: 32), + publicKey: Data(repeating: 0x22, count: 32), + npub: "npub1mock", + createdAt: Date(timeIntervalSince1970: 0) + ) + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { + if let deriveNostrIdentityError { throw deriveNostrIdentityError } + return Self.dummyIdentity + } +} + +// MARK: - Helpers + +/// Lets the coordinator's internal `Task { @MainActor โ€ฆ }` hops run. +@MainActor +private func drainMainActorTasks() async { + for _ in 0..<10 { await Task.yield() } +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatOutgoingCoordinator` against `MockChatOutgoingContext` with +/// no `ChatViewModel`. +struct ChatOutgoingCoordinatorContextTests { + + @Test @MainActor + func sendMessage_routesSlashCommandsAndDropsEmptyContent() async { + let context = MockChatOutgoingContext() + let coordinator = ChatOutgoingCoordinator(context: context) + + coordinator.sendMessage(" ") + coordinator.sendMessage("/who all") + await drainMainActorTasks() + + #expect(context.handledCommands == ["/who all"]) + #expect(context.appendedTimelineMessages.isEmpty) + #expect(context.sentMeshMessages.isEmpty) + } + + @Test @MainActor + func sendMessage_inPrivateChat_reResolvesPeerBeforeSending() async { + let context = MockChatOutgoingContext() + let coordinator = ChatOutgoingCoordinator(context: context) + let shortPeer = PeerID(str: "1111111111111111") + let stablePeer = PeerID(str: String(repeating: "ab", count: 32)) + + // The selected peer is refreshed (short โ†’ stable) before sending. + context.selectedPrivateChatPeer = shortPeer + context.selectedPeerAfterUpdate = stablePeer + coordinator.sendMessage("hi there") + #expect(context.updatePrivateChatPeerIfNeededCount == 1) + #expect(context.sentPrivateMessages.map(\.peerID) == [stablePeer]) + #expect(context.sentPrivateMessages.map(\.content) == ["hi there"]) + + // If the refresh clears the selection, nothing is sent. + context.selectedPeerAfterUpdate = PeerID??.some(nil) + coordinator.sendMessage("dropped") + await drainMainActorTasks() + #expect(context.sentPrivateMessages.count == 1) + #expect(context.appendedTimelineMessages.isEmpty) + } + + @Test @MainActor + func sendMessage_onMesh_appendsLocalEchoRecordsActivityAndSends() async { + let context = MockChatOutgoingContext() + let coordinator = ChatOutgoingCoordinator(context: context) + + coordinator.sendMessage(" hello @bob ") + await drainMainActorTasks() + + // Local echo uses the trimmed content, own nickname/peer ID, mentions. + #expect(context.appendedTimelineMessages.count == 1) + let echo = context.appendedTimelineMessages[0] + #expect(echo.message.content == "hello @bob") + #expect(echo.message.sender == "me") + #expect(echo.message.senderPeerID == context.myPeerID) + #expect(echo.message.mentions == ["bob"]) + #expect(echo.channel == .mesh) + #expect(context.refreshedChannels == [.mesh]) + #expect(context.recordedContentKeys.map(\.key) == ["key:hello @bob"]) + #expect(context.trimMessagesIfNeededCount == 1) + + // The mesh send carries the original (untrimmed) content and reuses + // the echo's message ID and timestamp; activity is stamped for "mesh". + #expect(context.recordedActivityKeys == ["mesh"]) + #expect(context.sentMeshMessages.count == 1) + let sent = context.sentMeshMessages[0] + #expect(sent.content == " hello @bob ") + #expect(sent.mentions == ["bob"]) + #expect(sent.messageID == echo.message.id) + #expect(sent.timestamp == echo.message.timestamp) + } + + @Test @MainActor + func sendMessage_onLocationChannel_sendsGeohashEventOrFailsWithSystemMessage() async { + let context = MockChatOutgoingContext() + let coordinator = ChatOutgoingCoordinator(context: context) + let channel = GeohashChannel(level: .city, geohash: "u4pruydq") + context.activeChannel = .location(channel) + context.isTeleported = true + + coordinator.sendMessage("hello geo") + await drainMainActorTasks() + + // Local echo carries the geohash sender suffix (#last-4-of-pubkey) and + // the signed event's ID; the send context targets the same channel. + #expect(context.appendedTimelineMessages.count == 1) + let echo = context.appendedTimelineMessages[0].message + #expect(echo.sender == "me#2222") + #expect(context.recordedActivityKeys == ["geo:u4pruydq"]) + #expect(context.sentGeohashContexts.count == 1) + let geoContext = context.sentGeohashContexts[0] + #expect(geoContext.channel == channel) + #expect(geoContext.teleported) + #expect(geoContext.event.id == echo.id) + + // Identity derivation failure: system message, no echo, no send. + context.deriveNostrIdentityError = MockChatOutgoingContext.IdentityUnavailable() + coordinator.sendMessage("doomed") + await drainMainActorTasks() + #expect(context.systemMessages.count == 1) + #expect(context.appendedTimelineMessages.count == 1) + #expect(context.sentGeohashContexts.count == 1) + } +} diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift new file mode 100644 index 00000000..85726978 --- /dev/null +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -0,0 +1,165 @@ +// +// ChatPeerListCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatPeerListCoordinator` against a mock `ChatPeerListContext` โ€” +// proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` / +// `ChatTransportEventCoordinatorContextTests` exemplars. +// +// Scope note: the network-availability notification path posts through +// `NotificationService.shared` (a singleton) and arms wall-clock timers. These +// tests keep every peer mesh-inactive (`isPeerConnected`/`isPeerReachable` +// both false) so that path is never reached; the timer-driven reset flows are +// covered by integration-level tests. +// + +import Testing +import Foundation +import BitFoundation +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `ChatPeerListContext` proving that +/// `ChatPeerListCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockChatPeerListContext: ChatPeerListContext { + // Connection & chat state + var isConnected = false + var privateChats: [PeerID: [BitchatMessage]] = [:] + var unreadPrivateMessages: Set = [] + var hasTrackedPrivateChatSelection = false + private(set) var updatePrivateChatPeerIfNeededCount = 0 + private(set) var cleanupOldReadReceiptsCount = 0 + + func updatePrivateChatPeerIfNeeded() { + updatePrivateChatPeerIfNeededCount += 1 + } + + func cleanupOldReadReceipts() { + cleanupOldReadReceiptsCount += 1 + } + + // Peers & sessions + var unifiedPeers: [BitchatPeer] = [] + var connectedMeshPeers: Set = [] + var reachableMeshPeers: Set = [] + var activeMeshPeerCountValue = 0 + private(set) var registeredEphemeralSessions: [PeerID] = [] + private(set) var updateEncryptionStatusForPeersCount = 0 + + func isPeerConnected(_ peerID: PeerID) -> Bool { connectedMeshPeers.contains(peerID) } + func isPeerReachable(_ peerID: PeerID) -> Bool { reachableMeshPeers.contains(peerID) } + func activeMeshPeerCount() -> Int { activeMeshPeerCountValue } + func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) } + func updateEncryptionStatusForPeers() { updateEncryptionStatusForPeersCount += 1 } +} + +// MARK: - Helpers + +/// Lets the coordinator's internal `Task { @MainActor โ€ฆ }` hops run. +@MainActor +private func drainMainActorTasks() async { + for _ in 0..<10 { await Task.yield() } +} + +private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMessage { + BitchatMessage( + id: id, + sender: "alice", + content: "hello", + timestamp: Date(), + isRelay: false, + isPrivate: true, + recipientNickname: "me", + senderPeerID: senderPeerID + ) +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `ChatPeerListCoordinator` against `MockChatPeerListContext` with +/// no `ChatViewModel`. +struct ChatPeerListCoordinatorContextTests { + + @Test @MainActor + func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + let peerA = PeerID(str: "0011223344556677") + let peerB = PeerID(str: "8899aabbccddeeff") + context.isConnected = true + + // Empty list: disconnected, read-receipt hygiene still runs, no sessions. + coordinator.didUpdatePeerList([]) + await drainMainActorTasks() + #expect(!context.isConnected) + #expect(context.cleanupOldReadReceiptsCount == 1) + #expect(context.registeredEphemeralSessions.isEmpty) + #expect(context.updateEncryptionStatusForPeersCount == 1) + + // Non-empty list: connected, every peer gets an ephemeral session. + coordinator.didUpdatePeerList([peerA, peerB]) + await drainMainActorTasks() + #expect(context.isConnected) + #expect(context.registeredEphemeralSessions == [peerA, peerB]) + #expect(context.updateEncryptionStatusForPeersCount == 2) + #expect(context.cleanupOldReadReceiptsCount == 2) + } + + @Test @MainActor + func didUpdatePeerList_refreshesPrivateChatPeerOnlyWhenSelectionIsTracked() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + let peerID = PeerID(str: "0011223344556677") + + coordinator.didUpdatePeerList([peerID]) + await drainMainActorTasks() + #expect(context.updatePrivateChatPeerIfNeededCount == 0) + + context.hasTrackedPrivateChatSelection = true + coordinator.didUpdatePeerList([peerID]) + await drainMainActorTasks() + #expect(context.updatePrivateChatPeerIfNeededCount == 1) + } + + @Test @MainActor + func didUpdatePeerList_removesStaleUnreadPeerIDsButKeepsBackedConversations() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + + let currentPeer = PeerID(str: "0011223344556677") + let staleShortPeer = PeerID(str: "8899aabbccddeeff") + let geoDMWithMessages = PeerID(str: "nostr_" + String(repeating: "ab", count: 8)) + let geoDMWithoutMessages = PeerID(str: "nostr_" + String(repeating: "cd", count: 8)) + let noiseKeyWithMessages = PeerID(str: String(repeating: "ef", count: 32)) + + context.unifiedPeers = [ + BitchatPeer( + peerID: currentPeer, + noisePublicKey: Data(repeating: 0x01, count: 32), + nickname: "alice" + ), + ] + context.unreadPrivateMessages = [ + currentPeer, + staleShortPeer, + geoDMWithMessages, + geoDMWithoutMessages, + noiseKeyWithMessages, + ] + context.privateChats = [ + geoDMWithMessages: [makeMessage(id: "geo-1")], + noiseKeyWithMessages: [makeMessage(id: "noise-1")], + ] + + coordinator.didUpdatePeerList([currentPeer]) + await drainMainActorTasks() + + // Stale IDs without a backing conversation are dropped; geo-DM and + // Noise-key IDs with stored messages survive, as does the live peer. + #expect(context.unreadPrivateMessages == [currentPeer, geoDMWithMessages, noiseKeyWithMessages]) + #expect(context.cleanupOldReadReceiptsCount == 1) + } +} diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index 666745f2..c4875321 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -418,9 +418,11 @@ struct ChatViewModelNostrExtensionTests { try await Task.sleep(nanoseconds: 150_000_000) #expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id)) + // Generous deadline: the detached verification task can be starved + // under parallel test load. viewModel.handleNostrMessage(giftWrap) var recorded = false - for _ in 0..<200 { + for _ in 0..<600 { if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { recorded = true break diff --git a/bitchatTests/GeoChannelCoordinatorContextTests.swift b/bitchatTests/GeoChannelCoordinatorContextTests.swift new file mode 100644 index 00000000..9345b341 --- /dev/null +++ b/bitchatTests/GeoChannelCoordinatorContextTests.swift @@ -0,0 +1,196 @@ +// +// GeoChannelCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `GeoChannelCoordinator` against a mock `GeoChannelContext` โ€” +// proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` exemplar. +// +// Scope note: the location/bookmark managers are real `LocationStateManager` +// instances backed by throwaway `UserDefaults` suites and mocked CoreLocation +// seams. `TorManager` has no test seam (private init singleton); sampling +// tests pin `TorManager.shared` to foreground (its default) so the +// begin-sampling branch is deterministic. +// + +import Testing +import Foundation +import CoreLocation +import Tor +@testable import bitchat + +// MARK: - Mock Context + +/// Lightweight stand-in for `GeoChannelContext` proving that +/// `GeoChannelCoordinator` is testable without a `ChatViewModel`. +@MainActor +private final class MockGeoChannelContext: GeoChannelContext { + private(set) var switchedChannels: [ChannelID] = [] + private(set) var beginSamplingCalls: [[String]] = [] + private(set) var endSamplingCount = 0 + + func switchLocationChannel(to channel: ChannelID) { switchedChannels.append(channel) } + func beginGeohashSampling(for geohashes: [String]) { beginSamplingCalls.append(geohashes.sorted()) } + func endGeohashSampling() { endSamplingCount += 1 } +} + +// MARK: - CoreLocation Seams + +private final class StubLocationManaging: LocationStateManaging { + weak var delegate: CLLocationManagerDelegate? + var desiredAccuracy: CLLocationAccuracy = 0 + var distanceFilter: CLLocationDistance = 0 + var authorizationStatus: CLAuthorizationStatus = .denied + + func requestWhenInUseAuthorization() {} + func requestLocation() {} + func startUpdatingLocation() {} + func stopUpdatingLocation() {} +} + +private final class StubLocationGeocoder: LocationStateGeocoding { + func cancelGeocode() {} + func reverseGeocodeLocation( + _ location: CLLocation, + completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void + ) { + completionHandler(nil, nil) + } +} + +// MARK: - Helpers + +@MainActor +private func makeLocationManager(storage: UserDefaults? = nil) -> LocationStateManager { + let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)" + let defaults = storage ?? UserDefaults(suiteName: suiteName)! + if storage == nil { + defaults.removePersistentDomain(forName: suiteName) + } + return LocationStateManager( + storage: defaults, + locationManager: StubLocationManaging(), + geocoder: StubLocationGeocoder(), + shouldInitializeCoreLocation: false + ) +} + +/// Polls until `condition` holds, letting main-actor tasks and main-queue +/// Combine hops drain in between. +@MainActor +private func waitUntil(_ condition: () -> Bool) async -> Bool { + for _ in 0..<100 { + if condition() { return true } + await Task.yield() + try? await Task.sleep(nanoseconds: 10_000_000) + } + return condition() +} + +// MARK: - Coordinator Tests Against Mock Context + +/// Exercises `GeoChannelCoordinator` against `MockGeoChannelContext` with no +/// `ChatViewModel`. +struct GeoChannelCoordinatorContextTests { + + @Test @MainActor + func start_publishesPersistedChannelAndEndsSamplingWithoutGeohashes() async throws { + let suiteName = "GeoChannelCoordinatorContextTests-\(UUID().uuidString)" + let storage = UserDefaults(suiteName: suiteName)! + storage.removePersistentDomain(forName: suiteName) + let persisted = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pru")) + storage.set(try JSONEncoder().encode(persisted), forKey: "locationChannel.selected") + + let locationManager = makeLocationManager(storage: storage) + let context = MockGeoChannelContext() + let coordinator = GeoChannelCoordinator( + locationManager: locationManager, + bookmarksStore: locationManager, + torManager: TorManager.shared, + context: context + ) + defer { withExtendedLifetime(coordinator) {} } + + // The persisted selection is announced and, with no regional or + // bookmarked geohashes, sampling ends rather than begins. + #expect(await waitUntil { !context.switchedChannels.isEmpty && context.endSamplingCount > 0 }) + #expect(context.switchedChannels.allSatisfy { $0 == persisted }) + #expect(context.beginSamplingCalls.isEmpty) + } + + @Test @MainActor + func selectingChannel_propagatesSwitchToContext() async { + let locationManager = makeLocationManager() + let context = MockGeoChannelContext() + let coordinator = GeoChannelCoordinator( + locationManager: locationManager, + bookmarksStore: locationManager, + torManager: TorManager.shared, + context: context + ) + defer { withExtendedLifetime(coordinator) {} } + + #expect(await waitUntil { context.switchedChannels.contains(.mesh) }) + + let target = ChannelID.location(GeohashChannel(level: .neighborhood, geohash: "u4pruydq")) + locationManager.select(target) + #expect(await waitUntil { context.switchedChannels.last == target }) + } + + @Test @MainActor + func bookmarkChanges_beginAndEndGeohashSampling() async { + TorManager.shared.setAppForeground(true) + let locationManager = makeLocationManager() + let context = MockGeoChannelContext() + let coordinator = GeoChannelCoordinator( + locationManager: locationManager, + bookmarksStore: locationManager, + torManager: TorManager.shared, + context: context + ) + + // No geohashes yet: only end-sampling has run. + #expect(await waitUntil { context.endSamplingCount > 0 }) + #expect(context.beginSamplingCalls.isEmpty) + + // Bookmarking a geohash starts sampling it. + locationManager.toggleBookmark("u4pruydq") + #expect(await waitUntil { context.beginSamplingCalls.last == ["u4pruydq"] }) + + // Removing the last bookmark ends sampling again, and a manual + // refresh keeps reporting the empty state. + let endCountBeforeRemoval = context.endSamplingCount + locationManager.toggleBookmark("u4pruydq") + #expect(await waitUntil { context.endSamplingCount > endCountBeforeRemoval }) + + let endCountBeforeRefresh = context.endSamplingCount + coordinator.refreshSampling() + #expect(await waitUntil { context.endSamplingCount > endCountBeforeRefresh }) + } + + @Test @MainActor + func releasedContext_isHeldWeaklyAndSafelyIgnored() async { + let locationManager = makeLocationManager() + var context: MockGeoChannelContext? = MockGeoChannelContext() + weak var weakContext = context + let coordinator = GeoChannelCoordinator( + locationManager: locationManager, + bookmarksStore: locationManager, + torManager: TorManager.shared, + context: context! + ) + + #expect(await waitUntil { context?.switchedChannels.isEmpty == false }) + + // The coordinator must not keep the owner alive (it is owned by it). + context = nil + #expect(weakContext == nil) + + // Events after the owner is gone are safely dropped. + locationManager.select(.location(GeohashChannel(level: .city, geohash: "u4pru"))) + locationManager.toggleBookmark("u4pruydq") + coordinator.refreshSampling() + for _ in 0..<10 { await Task.yield() } + try? await Task.sleep(nanoseconds: 50_000_000) + } +} From 638f3f500515b3ae11f7c691abc68df1c10bd378 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Jun 2026 22:28:08 +0200 Subject: [PATCH 6/9] Split ChatNostrCoordinator into owned components along domain boundaries The 1,109-line coordinator becomes a 187-line facade wiring three components, each with its own narrow context protocol: GeohashSubscriptionManager (384 lines - subscription IDs + relay lifecycle, the only NostrRelayManager toucher), NostrInboundPipeline (490 lines - the hot event path, dedup-before-verify ordering preserved verbatim), and GeoPresenceTracker (192 lines - teleport detection, sampling LRU, notification cooldowns, now directly tested). Perf baselines confirm the hot path is unchanged: fresh events 2,131 -> 2,138/sec, duplicates ~1.41M/sec. Co-Authored-By: Claude Fable 5 --- bitchat/ViewModels/ChatNostrCoordinator.swift | 978 +----------------- .../Extensions/ChatViewModel+Nostr.swift | 34 +- bitchat/ViewModels/GeoPresenceTracker.swift | 192 ++++ .../GeohashSubscriptionManager.swift | 384 +++++++ bitchat/ViewModels/NostrInboundPipeline.swift | 490 +++++++++ .../ChatNostrCoordinatorContextTests.swift | 122 ++- .../PerformanceBaselineTests.swift | 13 +- 7 files changed, 1227 insertions(+), 986 deletions(-) create mode 100644 bitchat/ViewModels/GeoPresenceTracker.swift create mode 100644 bitchat/ViewModels/GeohashSubscriptionManager.swift create mode 100644 bitchat/ViewModels/NostrInboundPipeline.swift diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index 96b35783..e56ce1db 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -1,102 +1,21 @@ import BitFoundation import BitLogger import Foundation -import SwiftUI -import Tor -/// The narrow surface `ChatNostrCoordinator` needs from its owner. +/// The surface `ChatNostrCoordinator` needs from its owner. /// -/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the -/// minimal context it actually uses instead of holding a back-reference to the -/// whole `ChatViewModel`. This keeps the coordinator independently testable -/// (see `ChatNostrCoordinatorContextTests`) and makes its true dependencies -/// explicit. The surface is intentionally large โ€” it documents the -/// coordinator's real coupling to channel/subscription state, the inbound -/// Nostr event pipeline, geohash presence, and the ack transports. +/// Inherits the component contexts (`GeohashSubscriptionContext`, +/// `NostrInboundPipelineContext`, `GeoPresenceContext`) so a single object โ€” +/// `ChatViewModel` in production, one mock in tests โ€” can back the whole +/// Nostr stack. The members declared here are only the residual +/// favorites/ack glue the slimmed coordinator still owns. @MainActor -protocol ChatNostrContext: AnyObject { - // MARK: Channel & subscription state - var activeChannel: ChannelID { get set } - var currentGeohash: String? { get set } - var geoSubscriptionID: String? { get } - var geoDmSubscriptionID: String? { get } - func setGeoChatSubscriptionID(_ id: String?) - func setGeoDmSubscriptionID(_ id: String?) - /// Geohash sampling subscriptions: subscription ID -> geohash. - var geoSamplingSubs: [String: String] { get } - func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) - func removeGeoSamplingSub(_ subID: String) - /// Clears all sampling subscriptions and returns the removed subscription IDs - /// so the caller can unsubscribe them from the relay manager. - func clearGeoSamplingSubs() -> [String] - /// Per-geohash notification cooldown: geohash -> last notify time. - var lastGeoNotificationAt: [String: Date] { get set } - var nostrRelayManager: NostrRelayManager? { get } - - // MARK: Public timeline & pipeline - var messages: [BitchatMessage] { get } - func resetPublicMessagePipeline() - func updatePublicMessagePipelineChannel(_ channel: ChannelID) - func refreshVisibleMessages(from channel: ChannelID?) - func addPublicSystemMessage(_ content: String) - func drainPendingGeohashSystemMessages() -> [String] - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool - func synchronizePublicConversationStore(forGeohash geohash: String) - - // MARK: Inbound public messages - func handlePublicMessage(_ message: BitchatMessage) - func checkForMentions(_ message: BitchatMessage) - func sendHapticFeedback(for message: BitchatMessage) - func parseMentions(from content: String) -> [String] - - // MARK: Inbound private (geohash DM) payloads +protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineContext, GeoPresenceContext { var selectedPrivateChatPeer: PeerID? { get } var nostrKeyMapping: [PeerID: String] { get } - /// Records the Nostr pubkey behind a (possibly virtual) peer ID. - func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) - func handlePrivateMessage( - _ payload: NoisePayload, - senderPubkey: String, - convKey: PeerID, - id: NostrIdentity, - messageTimestamp: Date - ) - func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) - func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) func startPrivateChat(with peerID: PeerID) - - // MARK: Nostr identity & blocking (shared with `ChatPrivateConversationContext`) - func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity - func currentNostrIdentity() -> NostrIdentity? - func isNostrBlocked(pubkeyHexLowercased: String) -> Bool - func displayNameForNostrPubkey(_ pubkeyHex: String) -> String - - // MARK: Event dedup - func hasProcessedNostrEvent(_ eventID: String) -> Bool - func recordProcessedNostrEvent(_ eventID: String) - func clearProcessedNostrEvents() - - // MARK: Geo participants & presence - var geoNicknames: [String: String] { get } - var teleportedGeoCount: Int { get } - func startGeoParticipantRefreshTimer() - func stopGeoParticipantRefreshTimer() - func setActiveParticipantGeohash(_ geohash: String?) - func recordGeoParticipant(pubkeyHex: String) - func recordGeoParticipant(pubkeyHex: String, geohash: String) - func geoParticipantCount(for geohash: String) -> Int - func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) - func markGeoTeleported(_ pubkeyHexLowercased: String) - func clearGeoTeleported(_ pubkeyHexLowercased: String) - func clearTeleportedGeo() - func clearGeoNicknames() func visibleGeohashPeople() -> [GeoPerson] - // MARK: Location channels - var isTeleported: Bool { get } - /// True when regional channels are known and the geohash is not one of them. - func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool - // MARK: Routing & acknowledgements (shared with `ChatPrivateConversationContext`) func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) @@ -104,857 +23,33 @@ protocol ChatNostrContext: AnyObject { } extension ChatViewModel: ChatNostrContext { - // `activeChannel`, `selectedPrivateChatPeer`, `nostrKeyMapping`, - // `messages`, `geoNicknames`, the Nostr identity/blocking members, and the - // routing/ack members are shared requirements with `ChatDeliveryContext` / - // `ChatPrivateConversationContext`; their witnesses already exist. The - // members below flatten nested service accesses into intent-named calls. - - func resetPublicMessagePipeline() { - publicMessagePipeline.reset() - } - - func updatePublicMessagePipelineChannel(_ channel: ChannelID) { - publicMessagePipeline.updateActiveChannel(channel) - } - - func drainPendingGeohashSystemMessages() -> [String] { - timelineStore.drainPendingGeohashSystemMessages() - } - - func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { - timelineStore.appendIfAbsent(message, toGeohash: geohash) - } - - func hasProcessedNostrEvent(_ eventID: String) -> Bool { - deduplicationService.hasProcessedNostrEvent(eventID) - } - - func recordProcessedNostrEvent(_ eventID: String) { - deduplicationService.recordNostrEvent(eventID) - } - - func clearProcessedNostrEvents() { - deduplicationService.clearNostrCaches() - } - - var teleportedGeoCount: Int { - locationPresenceStore.teleportedGeo.count - } - - func startGeoParticipantRefreshTimer() { - participantTracker.startRefreshTimer() - } - - func stopGeoParticipantRefreshTimer() { - participantTracker.stopRefreshTimer() - } - - func setActiveParticipantGeohash(_ geohash: String?) { - participantTracker.setActiveGeohash(geohash) - } - - func recordGeoParticipant(pubkeyHex: String) { - participantTracker.recordParticipant(pubkeyHex: pubkeyHex) - } - - func recordGeoParticipant(pubkeyHex: String, geohash: String) { - participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash) - } - - func geoParticipantCount(for geohash: String) -> Int { - participantTracker.participantCount(for: geohash) - } - - func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { - locationPresenceStore.setNickname(nickname, for: pubkeyHex) - } - - func markGeoTeleported(_ pubkeyHexLowercased: String) { - locationPresenceStore.markTeleported(pubkeyHexLowercased) - } - - func clearGeoTeleported(_ pubkeyHexLowercased: String) { - locationPresenceStore.clearTeleported(pubkeyHexLowercased) - } - - func clearTeleportedGeo() { - locationPresenceStore.clearTeleportedGeo() - } - - func clearGeoNicknames() { - locationPresenceStore.clearGeoNicknames() - } - - var isTeleported: Bool { - locationManager.teleported - } - - func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { - let channels = locationManager.availableChannels - return !channels.isEmpty && !channels.contains { $0.geohash == geohash } - } + // All requirements โ€” including the component-context witnesses declared + // in `GeohashSubscriptionManager.swift`, `NostrInboundPipeline.swift`, + // and `GeoPresenceTracker.swift` โ€” already exist on `ChatViewModel`. } +/// Thin facade over the Nostr stack: owns and wires the three components and +/// keeps the residual favorites/ack glue that fits none of them. +/// +/// - `subscriptions`: relay lifecycle and subscription IDs +/// (`GeohashSubscriptionManager`) +/// - `inbound`: the hot event -> message/payload pipeline +/// (`NostrInboundPipeline`) +/// - `presence`: teleport marking, sampling dedup, notification cooldown +/// (`GeoPresenceTracker`) final class ChatNostrCoordinator { private weak var context: (any ChatNostrContext)? - private var recentGeoSamplingEventIDs = Set() - private var recentGeoSamplingEventIDOrder: [String] = [] - private var geoEventLogCount = 0 + let presence: GeoPresenceTracker + let inbound: NostrInboundPipeline + let subscriptions: GeohashSubscriptionManager init(context: any ChatNostrContext) { self.context = context - } - - @MainActor - func resubscribeCurrentGeohash() { - guard let context else { return } - guard case .location(let channel) = context.activeChannel else { return } - guard let subID = context.geoSubscriptionID else { - switchLocationChannel(to: context.activeChannel) - return - } - - context.startGeoParticipantRefreshTimer() - NostrRelayManager.shared.unsubscribe(id: subID) - let filter = NostrFilter.geohashEphemeral( - channel.geohash, - since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), - limit: TransportConfig.nostrGeohashInitialLimit - ) - let subRelays = GeoRelayDirectory.shared.closestRelays( - toGeohash: channel.geohash, - count: TransportConfig.nostrGeoRelayCount - ) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - Task { @MainActor [weak self] in - self?.subscribeNostrEvent(event) - } - } - - if let dmSub = context.geoDmSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: dmSub) - context.setGeoDmSubscriptionID(nil) - } - - if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - let dmSub = "geo-dm-\(channel.geohash)" - context.setGeoDmSubscriptionID(dmSub) - let dmFilter = NostrFilter.giftWrapsFor( - pubkey: identity.publicKeyHex, - since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) - ) - NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in - Task { @MainActor [weak self] in - self?.subscribeGiftWrap(giftWrap, id: identity) - } - } - } - } - - @MainActor - func subscribeNostrEvent(_ event: NostrEvent) { - guard let context else { return } - // Cheap rejects (kind, dedup lookup) before Schnorr verification โ€” - // duplicates dominate real traffic and must not pay for crypto. - // Only verified events are recorded, so a forged-signature copy can - // never poison the dedup set and suppress the genuine event. - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue - || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), - !context.hasProcessedNostrEvent(event.id) - else { - return - } - guard event.isValidSignature() else { return } - - context.recordProcessedNostrEvent(event.id) - - if let gh = context.currentGeohash, - let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh), - myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) < 15 { - return - } - } - - if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { - let nick = nickTag[1].trimmed - context.setGeoNickname(nick, forPubkey: event.pubkey) - } - - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) - context.recordGeoParticipant(pubkeyHex: event.pubkey) - - if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { - return - } - - let hasTeleportTag = event.tags.contains { tag in - tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" - } - - if hasTeleportTag { - let key = event.pubkey.lowercased() - let isSelf: Bool = { - if let gh = context.currentGeohash, - let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) { - return myIdentity.publicKeyHex.lowercased() == key - } - return false - }() - if !isSelf { - Task { @MainActor [weak context] in - context?.markGeoTeleported(key) - } - } - } - - let senderName = context.displayNameForNostrPubkey(event.pubkey) - let content = event.content.trimmed - let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let timestamp = min(rawTs, Date()) - let mentions = context.parseMentions(from: content) - let message = BitchatMessage( - id: event.id, - sender: senderName, - content: content, - timestamp: timestamp, - isRelay: false, - senderPeerID: PeerID(nostr: event.pubkey), - mentions: mentions.isEmpty ? nil : mentions - ) - - Task { @MainActor [weak context] in - guard let context else { return } - let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) - context.handlePublicMessage(message) - if !isBlocked { - context.checkForMentions(message) - context.sendHapticFeedback(for: message) - } - } - } - - @MainActor - func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - guard let context else { return } - // Dedup lookup before Schnorr verification; record only after it passes. - guard !context.hasProcessedNostrEvent(giftWrap.id) else { return } - guard giftWrap.isValidSignature() else { return } - context.recordProcessedNostrEvent(giftWrap.id) - - guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: id - ), - let packet = Self.decodeEmbeddedBitChatPacket(from: content), - packet.type == MessageType.noiseEncrypted.rawValue, - let noisePayload = NoisePayload.decode(packet.payload) - else { - return - } - - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) - let convKey = PeerID(nostr_: senderPubkey) - context.registerNostrKeyMapping(senderPubkey, for: convKey) - - switch noisePayload.type { - case .privateMessage: - context.handlePrivateMessage( - noisePayload, - senderPubkey: senderPubkey, - convKey: convKey, - id: id, - messageTimestamp: messageTimestamp - ) - case .delivered: - context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .readReceipt: - context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: - break - } - } - - @MainActor - func switchLocationChannel(to channel: ChannelID) { - guard let context else { return } - context.resetPublicMessagePipeline() - context.activeChannel = channel - context.updatePublicMessagePipelineChannel(channel) - - context.clearProcessedNostrEvents() - switch channel { - case .mesh: - context.refreshVisibleMessages(from: .mesh) - let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count - if emptyMesh > 0 { - SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) - } - context.stopGeoParticipantRefreshTimer() - context.setActiveParticipantGeohash(nil) - context.clearTeleportedGeo() - - case .location: - context.refreshVisibleMessages(from: channel) - } - - if case .location = channel { - for content in context.drainPendingGeohashSystemMessages() { - context.addPublicSystemMessage(content) - } - } - - if let sub = context.geoSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: sub) - context.setGeoChatSubscriptionID(nil) - } - if let dmSub = context.geoDmSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: dmSub) - context.setGeoDmSubscriptionID(nil) - } - context.currentGeohash = nil - context.setActiveParticipantGeohash(nil) - context.clearGeoNicknames() - - guard case .location(let channel) = channel else { return } - context.currentGeohash = channel.geohash - context.setActiveParticipantGeohash(channel.geohash) - - if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) - let key = identity.publicKeyHex.lowercased() - if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { - context.markGeoTeleported(key) - SecureLogger.info( - "GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))โ€ฆ total=\(context.teleportedGeoCount)", - category: .session - ) - } else { - context.clearGeoTeleported(key) - } - } - - let subID = "geo-\(channel.geohash)" - context.setGeoChatSubscriptionID(subID) - context.startGeoParticipantRefreshTimer() - let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) - let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) - let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - Task { @MainActor [weak self] in - self?.handleNostrEvent(event) - } - } - - subscribeToGeoChat(channel) - } - - @MainActor - func handleNostrEvent(_ event: NostrEvent) { - guard let context else { return } - // Cheap rejects (kind, dedup lookup) before Schnorr verification โ€” - // duplicates dominate real traffic and must not pay for crypto. - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue - || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) - else { - return - } - if context.hasProcessedNostrEvent(event.id) { return } - guard event.isValidSignature() else { return } - context.recordProcessedNostrEvent(event.id) - - // Sampled: fires for every geo event and floods dev logs in busy geohashes. - geoEventLogCount += 1 - if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { - SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))โ€ฆ tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session) - } - - if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { - return - } - - let hasTeleportTag = event.tags.contains { tag in - tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" - } - - let isSelf: Bool = { - if let gh = context.currentGeohash, - let my = try? context.deriveNostrIdentity(forGeohash: gh) { - return my.publicKeyHex.lowercased() == event.pubkey.lowercased() - } - return false - }() - - if hasTeleportTag, !isSelf { - let key = event.pubkey.lowercased() - Task { @MainActor [weak context] in - guard let context else { return } - context.markGeoTeleported(key) - SecureLogger.info( - "GeoTeleport: mark peer teleported key=\(key.prefix(8))โ€ฆ total=\(context.teleportedGeoCount)", - category: .session - ) - } - } - - context.recordGeoParticipant(pubkeyHex: event.pubkey) - - if isSelf { - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) < 15 { - return - } - } - - if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { - context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey) - } - - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) - context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) - - if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { - return - } - - let senderName = context.displayNameForNostrPubkey(event.pubkey) - let content = event.content - - if let teleTag = event.tags.first(where: { $0.first == "t" }), - teleTag.count >= 2, - teleTag[1] == "teleport", - content.trimmed.isEmpty { - return - } - - let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - let mentions = context.parseMentions(from: content) - let message = BitchatMessage( - id: event.id, - sender: senderName, - content: content, - timestamp: min(rawTs, Date()), - isRelay: false, - senderPeerID: PeerID(nostr: event.pubkey), - mentions: mentions.isEmpty ? nil : mentions - ) - - Task { @MainActor [weak context] in - guard let context else { return } - context.handlePublicMessage(message) - context.checkForMentions(message) - context.sendHapticFeedback(for: message) - } - } - - @MainActor - func subscribeToGeoChat(_ channel: GeohashChannel) { - guard let context else { return } - guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return } - - let dmSub = "geo-dm-\(channel.geohash)" - context.setGeoDmSubscriptionID(dmSub) - if TorManager.shared.isReady { - SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))โ€ฆ sub=\(dmSub)", category: .session) - } - let dmFilter = NostrFilter.giftWrapsFor( - pubkey: identity.publicKeyHex, - since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) - ) - NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in - Task { @MainActor [weak self] in - self?.handleGiftWrap(giftWrap, id: identity) - } - } - } - - @MainActor - func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - guard let context else { return } - // Dedup lookup before Schnorr verification; record only after it passes. - if context.hasProcessedNostrEvent(giftWrap.id) { - return - } - guard giftWrap.isValidSignature() else { return } - context.recordProcessedNostrEvent(giftWrap.id) - - guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: id - ) else { - SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))โ€ฆ", category: .session) - return - } - - SecureLogger.debug( - "GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", - category: .session - ) - - guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), - packet.type == MessageType.noiseEncrypted.rawValue, - let payload = NoisePayload.decode(packet.payload) - else { - return - } - - let convKey = PeerID(nostr_: senderPubkey) - context.registerNostrKeyMapping(senderPubkey, for: convKey) - - switch payload.type { - case .privateMessage: - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) - context.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: convKey, - id: id, - messageTimestamp: messageTimestamp - ) - case .delivered: - context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) - case .readReceipt: - context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) - case .verifyChallenge, .verifyResponse: - break - } - } - - @MainActor - func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) { - guard let context else { return } - let channel = geoContext.channel - let event = geoContext.event - let identity = geoContext.identity - - let targetRelays = GeoRelayDirectory.shared.closestRelays( - toGeohash: channel.geohash, - count: TransportConfig.nostrGeoRelayCount - ) - - if targetRelays.isEmpty { - SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session) - } else { - NostrRelayManager.shared.sendEvent(event, to: targetRelays) - } - - context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) - context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) - SecureLogger.debug( - "GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))โ€ฆ teleported=\(geoContext.teleported)", - category: .session - ) - - if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { - let key = identity.publicKeyHex.lowercased() - context.markGeoTeleported(key) - SecureLogger.info( - "GeoTeleport: mark self teleported key=\(key.prefix(8))โ€ฆ total=\(context.teleportedGeoCount)", - category: .session - ) - } - - context.recordProcessedNostrEvent(event.id) - } - - @MainActor - func beginGeohashSampling(for geohashes: [String]) { - guard let context else { return } - if !TorManager.shared.isForeground() { - endGeohashSampling() - return - } - - let desired = Set(geohashes) - let current = Set(context.geoSamplingSubs.values) - let toAdd = desired.subtracting(current) - let toRemove = current.subtracting(desired) - - for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) { - NostrRelayManager.shared.unsubscribe(id: subID) - context.removeGeoSamplingSub(subID) - } - - for gh in toAdd { - subscribe(gh) - } - } - - @MainActor - func subscribe(_ gh: String) { - guard let context else { return } - let subID = "geo-sample-\(gh)" - context.addGeoSamplingSub(subID, forGeohash: gh) - let filter = NostrFilter.geohashEphemeral( - gh, - since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), - limit: TransportConfig.nostrGeohashSampleLimit - ) - let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in - Task { @MainActor [weak self] in - self?.subscribeNostrEvent(event, gh: gh) - } - } - } - - @MainActor - func subscribeNostrEvent(_ event: NostrEvent, gh: String) { - guard let context else { return } - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue - || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) - else { - return - } - guard event.isValidSignature() else { return } - guard shouldProcessGeoSamplingEvent(event.id) else { return } - - let existingCount = context.geoParticipantCount(for: gh) - context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) - - guard let content = event.content.trimmedOrNilIfEmpty else { return } - if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } - if let my = try? context.deriveNostrIdentity(forGeohash: gh), - my.publicKeyHex.lowercased() == event.pubkey.lowercased() { - return - } - guard existingCount == 0 else { return } - - let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) - if Date().timeIntervalSince(eventTime) > 30 { return } - - #if os(iOS) - guard UIApplication.shared.applicationState == .active else { return } - if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } - #elseif os(macOS) - guard NSApplication.shared.isActive else { return } - if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } - #endif - - cooldownPerGeohash(gh, content: content, event: event) - } - - @MainActor - func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { - guard let context else { return } - let now = Date() - let last = context.lastGeoNotificationAt[gh] ?? .distantPast - if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } - - let preview: String = { - let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen - if content.count <= maxLen { return content } - let idx = content.index(content.startIndex, offsetBy: maxLen) - return String(content[.. Bool { - guard !eventID.isEmpty else { return true } - guard recentGeoSamplingEventIDs.insert(eventID).inserted else { - return false - } - recentGeoSamplingEventIDOrder.append(eventID) - - let cap = TransportConfig.geoSamplingEventLRUCap - if recentGeoSamplingEventIDOrder.count > cap { - let removeCount = recentGeoSamplingEventIDOrder.count - cap - for staleID in recentGeoSamplingEventIDOrder.prefix(removeCount) { - recentGeoSamplingEventIDs.remove(staleID) - } - recentGeoSamplingEventIDOrder.removeFirst(removeCount) - } - return true - } - - private func clearGeoSamplingEventDedup() { - recentGeoSamplingEventIDs.removeAll() - recentGeoSamplingEventIDOrder.removeAll() - } - - @MainActor - func setupNostrMessageHandling() { - guard let context else { return } - guard let currentIdentity = context.currentNostrIdentity() else { - SecureLogger.warning("โš ๏ธ No Nostr identity available for message handling", category: .session) - return - } - - SecureLogger.debug( - "๐Ÿ”‘ Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", - category: .session - ) - - let filter = NostrFilter.giftWrapsFor( - pubkey: currentIdentity.publicKeyHex, - since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) - ) - - context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in - Task { @MainActor [weak self] in - self?.handleNostrMessage(event) - } - } - } - - @MainActor - func handleNostrMessage(_ giftWrap: NostrEvent) { - guard let context else { return } - // Cheap dedup pre-check only; Schnorr verification runs off-main in - // processNostrMessage, which then does the authoritative - // check-and-record. Recording stays after verification so a - // forged-signature copy can never poison the dedup set and suppress - // the genuine event. - if context.hasProcessedNostrEvent(giftWrap.id) { return } - - Task.detached(priority: .userInitiated) { [weak self] in - await self?.processNostrMessage(giftWrap) - } - } - - func processNostrMessage(_ giftWrap: NostrEvent) async { - guard giftWrap.isValidSignature() else { return } - guard let context else { return } - // Authoritative check-and-record, atomic on the main actor so two - // concurrent detached tasks can't both process the same event. - let alreadyProcessed: Bool = await MainActor.run { - if context.hasProcessedNostrEvent(giftWrap.id) { return true } - context.recordProcessedNostrEvent(giftWrap.id) - return false - } - if alreadyProcessed { return } - let currentIdentity: NostrIdentity? = await MainActor.run { - context.currentNostrIdentity() - } - guard let currentIdentity else { return } - - do { - let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage( - giftWrap: giftWrap, - recipientIdentity: currentIdentity - ) - - if content.hasPrefix("verify:") { - return - } - - if content.hasPrefix("bitchat1:") { - let packet: BitchatPacket? = await MainActor.run { - Self.decodeEmbeddedBitChatPacket(from: content) - } - guard let packet else { - SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) - return - } - - let actualSenderNoiseKey: Data? = await MainActor.run { - self.findNoiseKey(for: senderPubkey) - } - let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey) - - if packet.type == MessageType.noiseEncrypted.rawValue, - let payload = NoisePayload.decode(packet.payload) { - let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) - await MainActor.run { - context.registerNostrKeyMapping(senderPubkey, for: targetPeerID) - - switch payload.type { - case .privateMessage: - context.handlePrivateMessage( - payload, - senderPubkey: senderPubkey, - convKey: targetPeerID, - id: currentIdentity, - messageTimestamp: messageTimestamp - ) - case .delivered: - context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .readReceipt: - context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) - case .verifyChallenge, .verifyResponse: - break - } - } - } - } else { - SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session) - } - } catch { - SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session) - } - } - - @MainActor - func findNoiseKey(for nostrPubkey: String) -> Data? { - let favorites = FavoritesPersistenceService.shared.favorites.values - var npubToMatch = nostrPubkey - - if !nostrPubkey.hasPrefix("npub") { - if let pubkeyData = Data(hexString: nostrPubkey), - let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) { - npubToMatch = encoded - } else { - SecureLogger.warning( - "โš ๏ธ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", - category: .session - ) - } - } - - for relationship in favorites { - if let storedNostrKey = relationship.peerNostrPublicKey { - if storedNostrKey == npubToMatch { - return relationship.peerNoisePublicKey - } - if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey { - SecureLogger.debug("โœ… Found Noise key for Nostr sender (hex match)", category: .session) - return relationship.peerNoisePublicKey - } - } - } - - SecureLogger.debug( - "โš ๏ธ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", - category: .session - ) - return nil + let presence = GeoPresenceTracker(context: context) + let inbound = NostrInboundPipeline(context: context, presence: presence) + self.presence = presence + self.inbound = inbound + self.subscriptions = GeohashSubscriptionManager(context: context, inbound: inbound, presence: presence) } @MainActor @@ -994,7 +89,7 @@ final class ChatNostrCoordinator { @MainActor func handleFavoriteNotification(content: String, from nostrPubkey: String) { - guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return } + guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return } let isFavorite = content.contains("FAVORITE:TRUE") let senderNickname = content.components(separatedBy: "|").last ?? "Unknown" @@ -1090,20 +185,3 @@ final class ChatNostrCoordinator { return context.displayNameForNostrPubkey(full) } } - -private extension ChatNostrCoordinator { - @MainActor - static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { - guard content.hasPrefix("bitchat1:") else { return nil } - let encoded = String(content.dropFirst("bitchat1:".count)) - let maxBytes = FileTransferLimits.maxFramedFileBytes - let maxEncoded = ((maxBytes + 2) / 3) * 4 - guard encoded.count <= maxEncoded else { return nil } - guard let packetData = Base64URLCoding.decode(encoded), - packetData.count <= maxBytes - else { - return nil - } - return BitchatPacket.from(packetData) - } -} diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index fff0897e..bf2051bc 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -12,86 +12,86 @@ extension ChatViewModel { @MainActor func resubscribeCurrentGeohash() { - nostrCoordinator.resubscribeCurrentGeohash() + nostrCoordinator.subscriptions.resubscribeCurrentGeohash() } @MainActor func subscribeNostrEvent(_ event: NostrEvent) { - nostrCoordinator.subscribeNostrEvent(event) + nostrCoordinator.inbound.subscribeNostrEvent(event) } @MainActor func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - nostrCoordinator.subscribeGiftWrap(giftWrap, id: id) + nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id) } @MainActor func switchLocationChannel(to channel: ChannelID) { - nostrCoordinator.switchLocationChannel(to: channel) + nostrCoordinator.subscriptions.switchLocationChannel(to: channel) } @MainActor func handleNostrEvent(_ event: NostrEvent) { - nostrCoordinator.handleNostrEvent(event) + nostrCoordinator.inbound.handleNostrEvent(event) } @MainActor func subscribeToGeoChat(_ ch: GeohashChannel) { - nostrCoordinator.subscribeToGeoChat(ch) + nostrCoordinator.subscriptions.subscribeToGeoChat(ch) } @MainActor func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { - nostrCoordinator.handleGiftWrap(giftWrap, id: id) + nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id) } @MainActor func sendGeohash(context: GeoOutgoingContext) { - nostrCoordinator.sendGeohash(context: context) + nostrCoordinator.subscriptions.sendGeohash(context: context) } @MainActor func beginGeohashSampling(for geohashes: [String]) { - nostrCoordinator.beginGeohashSampling(for: geohashes) + nostrCoordinator.subscriptions.beginGeohashSampling(for: geohashes) } @MainActor func subscribe(_ gh: String) { - nostrCoordinator.subscribe(gh) + nostrCoordinator.subscriptions.subscribe(gh) } @MainActor func subscribeNostrEvent(_ event: NostrEvent, gh: String) { - nostrCoordinator.subscribeNostrEvent(event, gh: gh) + nostrCoordinator.presence.subscribeNostrEvent(event, gh: gh) } @MainActor func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { - nostrCoordinator.cooldownPerGeohash(gh, content: content, event: event) + nostrCoordinator.presence.cooldownPerGeohash(gh, content: content, event: event) } @MainActor func endGeohashSampling() { - nostrCoordinator.endGeohashSampling() + nostrCoordinator.subscriptions.endGeohashSampling() } @MainActor func setupNostrMessageHandling() { - nostrCoordinator.setupNostrMessageHandling() + nostrCoordinator.subscriptions.setupNostrMessageHandling() } @MainActor func handleNostrMessage(_ giftWrap: NostrEvent) { - nostrCoordinator.handleNostrMessage(giftWrap) + nostrCoordinator.inbound.handleNostrMessage(giftWrap) } func processNostrMessage(_ giftWrap: NostrEvent) async { - await nostrCoordinator.processNostrMessage(giftWrap) + await nostrCoordinator.inbound.processNostrMessage(giftWrap) } @MainActor func findNoiseKey(for nostrPubkey: String) -> Data? { - nostrCoordinator.findNoiseKey(for: nostrPubkey) + nostrCoordinator.inbound.findNoiseKey(for: nostrPubkey) } @MainActor diff --git a/bitchat/ViewModels/GeoPresenceTracker.swift b/bitchat/ViewModels/GeoPresenceTracker.swift new file mode 100644 index 00000000..587982c3 --- /dev/null +++ b/bitchat/ViewModels/GeoPresenceTracker.swift @@ -0,0 +1,192 @@ +import BitFoundation +import BitLogger +import Foundation +import SwiftUI + +/// The narrow surface `GeoPresenceTracker` needs from its owner. +/// +/// Split out of `ChatNostrContext`: member names are shared with the sibling +/// component contexts so `ChatViewModel` provides a single witness for each. +@MainActor +protocol GeoPresenceContext: AnyObject { + var activeChannel: ChannelID { get } + /// Per-geohash notification cooldown: geohash -> last notify time. + var lastGeoNotificationAt: [String: Date] { get set } + var geoNicknames: [String: String] { get } + var teleportedGeoCount: Int { get } + + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool + func parseMentions(from content: String) -> [String] + + func recordGeoParticipant(pubkeyHex: String, geohash: String) + func geoParticipantCount(for geohash: String) -> Int + func markGeoTeleported(_ pubkeyHexLowercased: String) + + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool + func synchronizePublicConversationStore(forGeohash geohash: String) +} + +extension ChatViewModel: GeoPresenceContext { + // `activeChannel`, `lastGeoNotificationAt`, `geoNicknames`, the Nostr + // identity/blocking members, and `synchronizePublicConversationStore` + // already have witnesses on `ChatViewModel`. The members below flatten + // nested service accesses into intent-named calls. + + func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool { + timelineStore.appendIfAbsent(message, toGeohash: geohash) + } + + var teleportedGeoCount: Int { + locationPresenceStore.teleportedGeo.count + } + + func recordGeoParticipant(pubkeyHex: String, geohash: String) { + participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash) + } + + func geoParticipantCount(for geohash: String) -> Int { + participantTracker.participantCount(for: geohash) + } + + func markGeoTeleported(_ pubkeyHexLowercased: String) { + locationPresenceStore.markTeleported(pubkeyHexLowercased) + } +} + +/// Geohash presence bookkeeping that is independent of relay subscriptions: +/// teleport-tag detection and marking, the sampling-event LRU dedup, and the +/// per-geohash notification cooldown for sampled activity. +final class GeoPresenceTracker { + private weak var context: (any GeoPresenceContext)? + private var recentGeoSamplingEventIDs = Set() + private var recentGeoSamplingEventIDOrder: [String] = [] + + init(context: any GeoPresenceContext) { + self.context = context + } + + /// True when the event carries a `["t", "teleport"]` tag. + static func hasTeleportTag(_ event: NostrEvent) -> Bool { + event.tags.contains { tag in + tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" + } + } + + /// Marks a peer teleported on a follow-up main-actor hop (keeps the + /// inbound hot path free of presence-store writes). + @MainActor + func scheduleMarkPeerTeleported(_ key: String, logged: Bool) { + Task { @MainActor [weak context] in + guard let context else { return } + context.markGeoTeleported(key) + if logged { + SecureLogger.info( + "GeoTeleport: mark peer teleported key=\(key.prefix(8))โ€ฆ total=\(context.teleportedGeoCount)", + category: .session + ) + } + } + } + + @MainActor + func subscribeNostrEvent(_ event: NostrEvent, gh: String) { + guard let context else { return } + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue + || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) + else { + return + } + guard event.isValidSignature() else { return } + guard shouldProcessGeoSamplingEvent(event.id) else { return } + + let existingCount = context.geoParticipantCount(for: gh) + context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) + + guard let content = event.content.trimmedOrNilIfEmpty else { return } + if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } + if let my = try? context.deriveNostrIdentity(forGeohash: gh), + my.publicKeyHex.lowercased() == event.pubkey.lowercased() { + return + } + guard existingCount == 0 else { return } + + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) > 30 { return } + + #if os(iOS) + guard UIApplication.shared.applicationState == .active else { return } + if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } + #elseif os(macOS) + guard NSApplication.shared.isActive else { return } + if case .location(let channel) = context.activeChannel, channel.geohash == gh { return } + #endif + + cooldownPerGeohash(gh, content: content, event: event) + } + + @MainActor + func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) { + guard let context else { return } + let now = Date() + let last = context.lastGeoNotificationAt[gh] ?? .distantPast + if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return } + + let preview: String = { + let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen + if content.count <= maxLen { return content } + let idx = content.index(content.startIndex, offsetBy: maxLen) + return String(content[.. Bool { + guard !eventID.isEmpty else { return true } + guard recentGeoSamplingEventIDs.insert(eventID).inserted else { + return false + } + recentGeoSamplingEventIDOrder.append(eventID) + + let cap = TransportConfig.geoSamplingEventLRUCap + if recentGeoSamplingEventIDOrder.count > cap { + let removeCount = recentGeoSamplingEventIDOrder.count - cap + for staleID in recentGeoSamplingEventIDOrder.prefix(removeCount) { + recentGeoSamplingEventIDs.remove(staleID) + } + recentGeoSamplingEventIDOrder.removeFirst(removeCount) + } + return true + } + + func clearGeoSamplingEventDedup() { + recentGeoSamplingEventIDs.removeAll() + recentGeoSamplingEventIDOrder.removeAll() + } +} diff --git a/bitchat/ViewModels/GeohashSubscriptionManager.swift b/bitchat/ViewModels/GeohashSubscriptionManager.swift new file mode 100644 index 00000000..1553410a --- /dev/null +++ b/bitchat/ViewModels/GeohashSubscriptionManager.swift @@ -0,0 +1,384 @@ +import BitFoundation +import BitLogger +import Foundation +import Tor + +/// The narrow surface `GeohashSubscriptionManager` needs from its owner. +/// +/// Split out of `ChatNostrContext`: member names are shared with the sibling +/// component contexts so `ChatViewModel` provides a single witness for each. +@MainActor +protocol GeohashSubscriptionContext: AnyObject { + // MARK: Channel & subscription state + var activeChannel: ChannelID { get set } + var currentGeohash: String? { get set } + var geoSubscriptionID: String? { get } + var geoDmSubscriptionID: String? { get } + func setGeoChatSubscriptionID(_ id: String?) + func setGeoDmSubscriptionID(_ id: String?) + /// Geohash sampling subscriptions: subscription ID -> geohash. + var geoSamplingSubs: [String: String] { get } + func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) + func removeGeoSamplingSub(_ subID: String) + /// Clears all sampling subscriptions and returns the removed subscription IDs + /// so the caller can unsubscribe them from the relay manager. + func clearGeoSamplingSubs() -> [String] + var nostrRelayManager: NostrRelayManager? { get } + + // MARK: Public timeline & pipeline + var messages: [BitchatMessage] { get } + func resetPublicMessagePipeline() + func updatePublicMessagePipelineChannel(_ channel: ChannelID) + func refreshVisibleMessages(from channel: ChannelID?) + func addPublicSystemMessage(_ content: String) + func drainPendingGeohashSystemMessages() -> [String] + + // MARK: Nostr identity & dedup + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity + func currentNostrIdentity() -> NostrIdentity? + func recordProcessedNostrEvent(_ eventID: String) + func clearProcessedNostrEvents() + /// Records the Nostr pubkey behind a (possibly virtual) peer ID. + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) + + // MARK: Geo participants & presence + var teleportedGeoCount: Int { get } + func startGeoParticipantRefreshTimer() + func stopGeoParticipantRefreshTimer() + func setActiveParticipantGeohash(_ geohash: String?) + func recordGeoParticipant(pubkeyHex: String) + func markGeoTeleported(_ pubkeyHexLowercased: String) + func clearGeoTeleported(_ pubkeyHexLowercased: String) + func clearTeleportedGeo() + func clearGeoNicknames() + + // MARK: Location channels + var isTeleported: Bool { get } + /// True when regional channels are known and the geohash is not one of them. + func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool +} + +extension ChatViewModel: GeohashSubscriptionContext { + // `activeChannel`, `currentGeohash`, the subscription-ID accessors, the + // identity members, and the timeline members already have witnesses on + // `ChatViewModel`. The members below flatten nested service accesses into + // intent-named calls. + + func resetPublicMessagePipeline() { + publicMessagePipeline.reset() + } + + func updatePublicMessagePipelineChannel(_ channel: ChannelID) { + publicMessagePipeline.updateActiveChannel(channel) + } + + func drainPendingGeohashSystemMessages() -> [String] { + timelineStore.drainPendingGeohashSystemMessages() + } + + func clearProcessedNostrEvents() { + deduplicationService.clearNostrCaches() + } + + func startGeoParticipantRefreshTimer() { + participantTracker.startRefreshTimer() + } + + func stopGeoParticipantRefreshTimer() { + participantTracker.stopRefreshTimer() + } + + func setActiveParticipantGeohash(_ geohash: String?) { + participantTracker.setActiveGeohash(geohash) + } + + func clearGeoTeleported(_ pubkeyHexLowercased: String) { + locationPresenceStore.clearTeleported(pubkeyHexLowercased) + } + + func clearTeleportedGeo() { + locationPresenceStore.clearTeleportedGeo() + } + + func clearGeoNicknames() { + locationPresenceStore.clearGeoNicknames() + } + + var isTeleported: Bool { + locationManager.teleported + } + + func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool { + let channels = locationManager.availableChannels + return !channels.isEmpty && !channels.contains { $0.geohash == geohash } + } +} + +/// Owns subscription IDs and relay lifecycle for geohash channels, geohash +/// DMs, the account gift-wrap mailbox, and background geohash sampling. The +/// only component that talks to `NostrRelayManager`; inbound events are +/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`. +final class GeohashSubscriptionManager { + private weak var context: (any GeohashSubscriptionContext)? + private let inbound: NostrInboundPipeline + private let presence: GeoPresenceTracker + + init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) { + self.context = context + self.inbound = inbound + self.presence = presence + } + + @MainActor + func resubscribeCurrentGeohash() { + guard let context else { return } + guard case .location(let channel) = context.activeChannel else { return } + guard let subID = context.geoSubscriptionID else { + switchLocationChannel(to: context.activeChannel) + return + } + + context.startGeoParticipantRefreshTimer() + NostrRelayManager.shared.unsubscribe(id: subID) + let filter = NostrFilter.geohashEphemeral( + channel.geohash, + since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), + limit: TransportConfig.nostrGeohashInitialLimit + ) + let subRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: channel.geohash, + count: TransportConfig.nostrGeoRelayCount + ) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + Task { @MainActor [weak self] in + self?.inbound.subscribeNostrEvent(event) + } + } + + if let dmSub = context.geoDmSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: dmSub) + context.setGeoDmSubscriptionID(nil) + } + + if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { + let dmSub = "geo-dm-\(channel.geohash)" + context.setGeoDmSubscriptionID(dmSub) + let dmFilter = NostrFilter.giftWrapsFor( + pubkey: identity.publicKeyHex, + since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) + ) + NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + Task { @MainActor [weak self] in + self?.inbound.subscribeGiftWrap(giftWrap, id: identity) + } + } + } + } + + @MainActor + func switchLocationChannel(to channel: ChannelID) { + guard let context else { return } + context.resetPublicMessagePipeline() + context.activeChannel = channel + context.updatePublicMessagePipelineChannel(channel) + + context.clearProcessedNostrEvents() + switch channel { + case .mesh: + context.refreshVisibleMessages(from: .mesh) + let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count + if emptyMesh > 0 { + SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) + } + context.stopGeoParticipantRefreshTimer() + context.setActiveParticipantGeohash(nil) + context.clearTeleportedGeo() + + case .location: + context.refreshVisibleMessages(from: channel) + } + + if case .location = channel { + for content in context.drainPendingGeohashSystemMessages() { + context.addPublicSystemMessage(content) + } + } + + if let sub = context.geoSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: sub) + context.setGeoChatSubscriptionID(nil) + } + if let dmSub = context.geoDmSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: dmSub) + context.setGeoDmSubscriptionID(nil) + } + context.currentGeohash = nil + context.setActiveParticipantGeohash(nil) + context.clearGeoNicknames() + + guard case .location(let channel) = channel else { return } + context.currentGeohash = channel.geohash + context.setActiveParticipantGeohash(channel.geohash) + + if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + let key = identity.publicKeyHex.lowercased() + if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { + context.markGeoTeleported(key) + SecureLogger.info( + "GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))โ€ฆ total=\(context.teleportedGeoCount)", + category: .session + ) + } else { + context.clearGeoTeleported(key) + } + } + + let subID = "geo-\(channel.geohash)" + context.setGeoChatSubscriptionID(subID) + context.startGeoParticipantRefreshTimer() + let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) + let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + Task { @MainActor [weak self] in + self?.inbound.handleNostrEvent(event) + } + } + + subscribeToGeoChat(channel) + } + + @MainActor + func subscribeToGeoChat(_ channel: GeohashChannel) { + guard let context else { return } + guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return } + + let dmSub = "geo-dm-\(channel.geohash)" + context.setGeoDmSubscriptionID(dmSub) + if TorManager.shared.isReady { + SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))โ€ฆ sub=\(dmSub)", category: .session) + } + let dmFilter = NostrFilter.giftWrapsFor( + pubkey: identity.publicKeyHex, + since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) + ) + NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + Task { @MainActor [weak self] in + self?.inbound.handleGiftWrap(giftWrap, id: identity) + } + } + } + + @MainActor + func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) { + guard let context else { return } + let channel = geoContext.channel + let event = geoContext.event + let identity = geoContext.identity + + let targetRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: channel.geohash, + count: TransportConfig.nostrGeoRelayCount + ) + + if targetRelays.isEmpty { + SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } + + context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex)) + SecureLogger.debug( + "GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))โ€ฆ teleported=\(geoContext.teleported)", + category: .session + ) + + if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) { + let key = identity.publicKeyHex.lowercased() + context.markGeoTeleported(key) + SecureLogger.info( + "GeoTeleport: mark self teleported key=\(key.prefix(8))โ€ฆ total=\(context.teleportedGeoCount)", + category: .session + ) + } + + context.recordProcessedNostrEvent(event.id) + } + + @MainActor + func beginGeohashSampling(for geohashes: [String]) { + guard let context else { return } + if !TorManager.shared.isForeground() { + endGeohashSampling() + return + } + + let desired = Set(geohashes) + let current = Set(context.geoSamplingSubs.values) + let toAdd = desired.subtracting(current) + let toRemove = current.subtracting(desired) + + for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) { + NostrRelayManager.shared.unsubscribe(id: subID) + context.removeGeoSamplingSub(subID) + } + + for gh in toAdd { + subscribe(gh) + } + } + + @MainActor + func subscribe(_ gh: String) { + guard let context else { return } + let subID = "geo-sample-\(gh)" + context.addGeoSamplingSub(subID, forGeohash: gh) + let filter = NostrFilter.geohashEphemeral( + gh, + since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds), + limit: TransportConfig.nostrGeohashSampleLimit + ) + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + Task { @MainActor [weak self] in + self?.presence.subscribeNostrEvent(event, gh: gh) + } + } + } + + @MainActor + func endGeohashSampling() { + guard let context else { return } + for subID in context.clearGeoSamplingSubs() { + NostrRelayManager.shared.unsubscribe(id: subID) + } + presence.clearGeoSamplingEventDedup() + } + + @MainActor + func setupNostrMessageHandling() { + guard let context else { return } + guard let currentIdentity = context.currentNostrIdentity() else { + SecureLogger.warning("โš ๏ธ No Nostr identity available for message handling", category: .session) + return + } + + SecureLogger.debug( + "๐Ÿ”‘ Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...", + category: .session + ) + + let filter = NostrFilter.giftWrapsFor( + pubkey: currentIdentity.publicKeyHex, + since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) + ) + + context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in + Task { @MainActor [weak self] in + self?.inbound.handleNostrMessage(event) + } + } + } +} diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift new file mode 100644 index 00000000..a1d4b4d5 --- /dev/null +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -0,0 +1,490 @@ +import BitFoundation +import BitLogger +import Foundation + +/// The narrow surface `NostrInboundPipeline` needs from its owner. +/// +/// Split out of `ChatNostrContext`: member names are shared with the sibling +/// component contexts so `ChatViewModel` provides a single witness for each. +@MainActor +protocol NostrInboundPipelineContext: AnyObject { + var currentGeohash: String? { get } + + // MARK: Event dedup + func hasProcessedNostrEvent(_ eventID: String) -> Bool + func recordProcessedNostrEvent(_ eventID: String) + + // MARK: Nostr identity & blocking + func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity + func currentNostrIdentity() -> NostrIdentity? + func isNostrBlocked(pubkeyHexLowercased: String) -> Bool + func displayNameForNostrPubkey(_ pubkeyHex: String) -> String + + // MARK: Presence & key mapping + func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) + /// Records the Nostr pubkey behind a (possibly virtual) peer ID. + func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) + func recordGeoParticipant(pubkeyHex: String) + + // MARK: Inbound public messages + func handlePublicMessage(_ message: BitchatMessage) + func checkForMentions(_ message: BitchatMessage) + func sendHapticFeedback(for message: BitchatMessage) + func parseMentions(from content: String) -> [String] + + // MARK: Inbound private (DM) payloads + func handlePrivateMessage( + _ payload: NoisePayload, + senderPubkey: String, + convKey: PeerID, + id: NostrIdentity, + messageTimestamp: Date + ) + func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) + func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) +} + +extension ChatViewModel: NostrInboundPipelineContext { + // `currentGeohash`, the identity/blocking members, key mapping, and the + // inbound message handlers already have witnesses on `ChatViewModel`. + // The members below flatten nested service accesses into intent-named calls. + + func hasProcessedNostrEvent(_ eventID: String) -> Bool { + deduplicationService.hasProcessedNostrEvent(eventID) + } + + func recordProcessedNostrEvent(_ eventID: String) { + deduplicationService.recordNostrEvent(eventID) + } + + func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { + locationPresenceStore.setNickname(nickname, for: pubkeyHex) + } + + func recordGeoParticipant(pubkeyHex: String) { + participantTracker.recordParticipant(pubkeyHex: pubkeyHex) + } +} + +/// The inbound Nostr hot path: raw relay events in, chat messages / Noise +/// payloads out. Pure transformation plus dedup โ€” no relay lifecycle. +/// +/// Ordering is deliberate and performance-critical: cheap rejects (kind, +/// dedup lookup) run BEFORE Schnorr signature verification because duplicates +/// dominate real relay traffic; events are recorded only AFTER verification so +/// a forged-signature copy can never poison the dedup set; gift-wrap +/// verification for the account mailbox runs off-main with an atomic +/// main-actor check-and-record. +final class NostrInboundPipeline { + private weak var context: (any NostrInboundPipelineContext)? + private let presence: GeoPresenceTracker + private var geoEventLogCount = 0 + + init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) { + self.context = context + self.presence = presence + } + + @MainActor + func subscribeNostrEvent(_ event: NostrEvent) { + guard let context else { return } + // Cheap rejects (kind, dedup lookup) before Schnorr verification โ€” + // duplicates dominate real traffic and must not pay for crypto. + // Only verified events are recorded, so a forged-signature copy can + // never poison the dedup set and suppress the genuine event. + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue + || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), + !context.hasProcessedNostrEvent(event.id) + else { + return + } + guard event.isValidSignature() else { return } + + context.recordProcessedNostrEvent(event.id) + + if let gh = context.currentGeohash, + let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh), + myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) < 15 { + return + } + } + + if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { + let nick = nickTag[1].trimmed + context.setGeoNickname(nick, forPubkey: event.pubkey) + } + + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) + context.recordGeoParticipant(pubkeyHex: event.pubkey) + + if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { + return + } + + if GeoPresenceTracker.hasTeleportTag(event) { + let key = event.pubkey.lowercased() + let isSelf: Bool = { + if let gh = context.currentGeohash, + let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) { + return myIdentity.publicKeyHex.lowercased() == key + } + return false + }() + if !isSelf { + presence.scheduleMarkPeerTeleported(key, logged: false) + } + } + + let senderName = context.displayNameForNostrPubkey(event.pubkey) + let content = event.content.trimmed + let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + let timestamp = min(rawTs, Date()) + let mentions = context.parseMentions(from: content) + let message = BitchatMessage( + id: event.id, + sender: senderName, + content: content, + timestamp: timestamp, + isRelay: false, + senderPeerID: PeerID(nostr: event.pubkey), + mentions: mentions.isEmpty ? nil : mentions + ) + + Task { @MainActor [weak context] in + guard let context else { return } + let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) + context.handlePublicMessage(message) + if !isBlocked { + context.checkForMentions(message) + context.sendHapticFeedback(for: message) + } + } + } + + @MainActor + func handleNostrEvent(_ event: NostrEvent) { + guard let context else { return } + // Cheap rejects (kind, dedup lookup) before Schnorr verification โ€” + // duplicates dominate real traffic and must not pay for crypto. + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue + || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) + else { + return + } + if context.hasProcessedNostrEvent(event.id) { return } + guard event.isValidSignature() else { return } + context.recordProcessedNostrEvent(event.id) + + // Sampled: fires for every geo event and floods dev logs in busy geohashes. + geoEventLogCount += 1 + if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) { + SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))โ€ฆ tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session) + } + + if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { + return + } + + let hasTeleportTag = GeoPresenceTracker.hasTeleportTag(event) + + let isSelf: Bool = { + if let gh = context.currentGeohash, + let my = try? context.deriveNostrIdentity(forGeohash: gh) { + return my.publicKeyHex.lowercased() == event.pubkey.lowercased() + } + return false + }() + + if hasTeleportTag, !isSelf { + presence.scheduleMarkPeerTeleported(event.pubkey.lowercased(), logged: true) + } + + context.recordGeoParticipant(pubkeyHex: event.pubkey) + + if isSelf { + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) < 15 { + return + } + } + + if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { + context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey) + } + + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey)) + context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey)) + + if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { + return + } + + let senderName = context.displayNameForNostrPubkey(event.pubkey) + let content = event.content + + if let teleTag = event.tags.first(where: { $0.first == "t" }), + teleTag.count >= 2, + teleTag[1] == "teleport", + content.trimmed.isEmpty { + return + } + + let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + let mentions = context.parseMentions(from: content) + let message = BitchatMessage( + id: event.id, + sender: senderName, + content: content, + timestamp: min(rawTs, Date()), + isRelay: false, + senderPeerID: PeerID(nostr: event.pubkey), + mentions: mentions.isEmpty ? nil : mentions + ) + + Task { @MainActor [weak context] in + guard let context else { return } + context.handlePublicMessage(message) + context.checkForMentions(message) + context.sendHapticFeedback(for: message) + } + } + + @MainActor + func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { + guard let context else { return } + // Dedup lookup before Schnorr verification; record only after it passes. + guard !context.hasProcessedNostrEvent(giftWrap.id) else { return } + guard giftWrap.isValidSignature() else { return } + context.recordProcessedNostrEvent(giftWrap.id) + + guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: id + ), + let packet = Self.decodeEmbeddedBitChatPacket(from: content), + packet.type == MessageType.noiseEncrypted.rawValue, + let noisePayload = NoisePayload.decode(packet.payload) + else { + return + } + + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) + let convKey = PeerID(nostr_: senderPubkey) + context.registerNostrKeyMapping(senderPubkey, for: convKey) + + switch noisePayload.type { + case .privateMessage: + context.handlePrivateMessage( + noisePayload, + senderPubkey: senderPubkey, + convKey: convKey, + id: id, + messageTimestamp: messageTimestamp + ) + case .delivered: + context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey) + case .readReceipt: + context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey) + case .verifyChallenge, .verifyResponse: + break + } + } + + @MainActor + func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { + guard let context else { return } + // Dedup lookup before Schnorr verification; record only after it passes. + if context.hasProcessedNostrEvent(giftWrap.id) { + return + } + guard giftWrap.isValidSignature() else { return } + context.recordProcessedNostrEvent(giftWrap.id) + + guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: id + ) else { + SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))โ€ฆ", category: .session) + return + } + + SecureLogger.debug( + "GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", + category: .session + ) + + guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), + packet.type == MessageType.noiseEncrypted.rawValue, + let payload = NoisePayload.decode(packet.payload) + else { + return + } + + let convKey = PeerID(nostr_: senderPubkey) + context.registerNostrKeyMapping(senderPubkey, for: convKey) + + switch payload.type { + case .privateMessage: + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) + context.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: convKey, + id: id, + messageTimestamp: messageTimestamp + ) + case .delivered: + context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) + case .readReceipt: + context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) + case .verifyChallenge, .verifyResponse: + break + } + } + + @MainActor + func handleNostrMessage(_ giftWrap: NostrEvent) { + guard let context else { return } + // Cheap dedup pre-check only; Schnorr verification runs off-main in + // processNostrMessage, which then does the authoritative + // check-and-record. Recording stays after verification so a + // forged-signature copy can never poison the dedup set and suppress + // the genuine event. + if context.hasProcessedNostrEvent(giftWrap.id) { return } + + Task.detached(priority: .userInitiated) { [weak self] in + await self?.processNostrMessage(giftWrap) + } + } + + func processNostrMessage(_ giftWrap: NostrEvent) async { + guard giftWrap.isValidSignature() else { return } + guard let context else { return } + // Authoritative check-and-record, atomic on the main actor so two + // concurrent detached tasks can't both process the same event. + let alreadyProcessed: Bool = await MainActor.run { + if context.hasProcessedNostrEvent(giftWrap.id) { return true } + context.recordProcessedNostrEvent(giftWrap.id) + return false + } + if alreadyProcessed { return } + let currentIdentity: NostrIdentity? = await MainActor.run { + context.currentNostrIdentity() + } + guard let currentIdentity else { return } + + do { + let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage( + giftWrap: giftWrap, + recipientIdentity: currentIdentity + ) + + if content.hasPrefix("verify:") { + return + } + + if content.hasPrefix("bitchat1:") { + let packet: BitchatPacket? = await MainActor.run { + Self.decodeEmbeddedBitChatPacket(from: content) + } + guard let packet else { + SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) + return + } + + let actualSenderNoiseKey: Data? = await MainActor.run { + self.findNoiseKey(for: senderPubkey) + } + let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey) + + if packet.type == MessageType.noiseEncrypted.rawValue, + let payload = NoisePayload.decode(packet.payload) { + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) + await MainActor.run { + context.registerNostrKeyMapping(senderPubkey, for: targetPeerID) + + switch payload.type { + case .privateMessage: + context.handlePrivateMessage( + payload, + senderPubkey: senderPubkey, + convKey: targetPeerID, + id: currentIdentity, + messageTimestamp: messageTimestamp + ) + case .delivered: + context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) + case .readReceipt: + context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) + case .verifyChallenge, .verifyResponse: + break + } + } + } + } else { + SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session) + } + } catch { + SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session) + } + } + + /// Resolves the Noise static key behind a Nostr pubkey via the favorites + /// store. Lives here because the inbound DM path needs it per message; + /// the favorites glue in `ChatNostrCoordinator` delegates to it. + @MainActor + func findNoiseKey(for nostrPubkey: String) -> Data? { + let favorites = FavoritesPersistenceService.shared.favorites.values + var npubToMatch = nostrPubkey + + if !nostrPubkey.hasPrefix("npub") { + if let pubkeyData = Data(hexString: nostrPubkey), + let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) { + npubToMatch = encoded + } else { + SecureLogger.warning( + "โš ๏ธ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...", + category: .session + ) + } + } + + for relationship in favorites { + if let storedNostrKey = relationship.peerNostrPublicKey { + if storedNostrKey == npubToMatch { + return relationship.peerNoisePublicKey + } + if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey { + SecureLogger.debug("โœ… Found Noise key for Nostr sender (hex match)", category: .session) + return relationship.peerNoisePublicKey + } + } + } + + SecureLogger.debug( + "โš ๏ธ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)", + category: .session + ) + return nil + } +} + +private extension NostrInboundPipeline { + @MainActor + static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { + guard content.hasPrefix("bitchat1:") else { return nil } + let encoded = String(content.dropFirst("bitchat1:".count)) + let maxBytes = FileTransferLimits.maxFramedFileBytes + let maxEncoded = ((maxBytes + 2) / 3) * 4 + guard encoded.count <= maxEncoded else { return nil } + guard let packetData = Base64URLCoding.decode(encoded), + packetData.count <= maxBytes + else { + return nil + } + return BitchatPacket.from(packetData) + } +} diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 30d208e4..795d6e8e 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -2,10 +2,11 @@ // ChatNostrCoordinatorContextTests.swift // bitchatTests // -// Exercises `ChatNostrCoordinator` against a mock `ChatNostrContext` โ€” -// proving the coordinator works without a `ChatViewModel`, following the -// `ChatDeliveryCoordinatorContextTests` / `ChatPrivateConversationCoordinatorContextTests` -// exemplars. +// Exercises the `ChatNostrCoordinator` facade and its components +// (`NostrInboundPipeline`, `GeohashSubscriptionManager`, `GeoPresenceTracker`) +// against a mock `ChatNostrContext` โ€” proving the stack works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. // import Testing @@ -15,8 +16,9 @@ import BitFoundation // MARK: - Mock Context -/// Lightweight stand-in for `ChatNostrContext` proving that -/// `ChatNostrCoordinator` is testable without a `ChatViewModel`. +/// Lightweight stand-in for `ChatNostrContext` (and, via protocol +/// inheritance, the component contexts) proving that the Nostr stack is +/// testable without a `ChatViewModel`. @MainActor private final class MockChatNostrContext: ChatNostrContext { // Channel & subscription state @@ -250,7 +252,7 @@ struct ChatNostrCoordinatorContextTests { ) context.displayNamesByPubkey[event.pubkey] = "alice#1234" - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) await drainMainQueue() // Dedup recorded exactly once, presence and key mapping updated. @@ -268,7 +270,7 @@ struct ChatNostrCoordinatorContextTests { #expect(context.hapticMessageIDs == [event.id]) // A replay of the same event is dropped before any processing. - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) await drainMainQueue() #expect(context.recordedNostrEventIDs == [event.id]) #expect(context.handledPublicMessages.count == 1) @@ -288,7 +290,7 @@ struct ChatNostrCoordinatorContextTests { teleported: true ) - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) await drainMainQueue() // Teleport detection fires even though the empty message is dropped. @@ -310,7 +312,7 @@ struct ChatNostrCoordinatorContextTests { ) context.blockedNostrPubkeys.insert(event.pubkey.lowercased()) - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) await drainMainQueue() // The event is still recorded for dedup but nothing else happens. @@ -337,7 +339,7 @@ struct ChatNostrCoordinatorContextTests { senderIdentity: sender ) - coordinator.handleGiftWrap(giftWrap, id: recipient) + coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) let convKey = PeerID(nostr_: sender.publicKeyHex) #expect(context.recordedNostrEventIDs == [giftWrap.id]) @@ -354,7 +356,7 @@ struct ChatNostrCoordinatorContextTests { #expect(pm.content == "psst") // The same gift wrap is dropped on replay. - coordinator.handleGiftWrap(giftWrap, id: recipient) + coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) #expect(context.recordedNostrEventIDs == [giftWrap.id]) #expect(context.handledPrivateMessages.count == 1) } @@ -367,7 +369,7 @@ struct ChatNostrCoordinatorContextTests { context.currentGeohash = "u4pruyd" context.geoNicknames = ["abcd": "alice"] - coordinator.switchLocationChannel(to: .mesh) + coordinator.subscriptions.switchLocationChannel(to: .mesh) #expect(context.pipelineResetCount == 1) #expect(context.activeChannel == .mesh) @@ -451,3 +453,97 @@ struct ChatNostrCoordinatorContextTests { #expect(coordinator.nostrPubkeyForDisplayName("nobody") == nil) } } + +// MARK: - GeoPresenceTracker Tests + +/// Focused tests for seams the coordinator split made independently +/// testable: the sampling-event LRU dedup and the per-geohash notification +/// cooldown. The cooldown tests stop short of the live notification center by +/// pre-seeding the timeline append as a duplicate. +struct GeoPresenceTrackerTests { + + @Test @MainActor + func samplingEventDedup_evictsOldestBeyondLRUCap() { + let context = MockChatNostrContext() + let tracker = GeoPresenceTracker(context: context) + let cap = TransportConfig.geoSamplingEventLRUCap + + // Empty IDs are never deduplicated. + #expect(tracker.shouldProcessGeoSamplingEvent("")) + #expect(tracker.shouldProcessGeoSamplingEvent("")) + + // First sight passes; a replay is rejected. + #expect(tracker.shouldProcessGeoSamplingEvent("ev-0")) + #expect(!tracker.shouldProcessGeoSamplingEvent("ev-0")) + + // Fill one past the cap: the oldest entry is evicted and accepted + // again, while a still-resident entry stays deduplicated. + for i in 1...cap { + #expect(tracker.shouldProcessGeoSamplingEvent("ev-\(i)")) + } + #expect(tracker.shouldProcessGeoSamplingEvent("ev-0")) + #expect(!tracker.shouldProcessGeoSamplingEvent("ev-\(cap)")) + + // Clearing resets the dedup entirely. + tracker.clearGeoSamplingEventDedup() + #expect(tracker.shouldProcessGeoSamplingEvent("ev-\(cap)")) + } + + @Test @MainActor + func notificationCooldown_skipsWithinWindow() async throws { + let context = MockChatNostrContext() + let tracker = GeoPresenceTracker(context: context) + let sender = try NostrIdentity.generate() + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: "sampled activity", + geohash: "9q8yy", + senderIdentity: sender, + nickname: "alice" + ) + + // Within the cooldown window nothing is appended or re-stamped. + let recent = Date() + context.lastGeoNotificationAt["9q8yy"] = recent + tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event) + await drainMainQueue() + #expect(context.appendedGeohashMessages.isEmpty) + #expect(context.lastGeoNotificationAt["9q8yy"] == recent) + #expect(context.synchronizedGeohashes.isEmpty) + } + + @Test @MainActor + func notificationCooldown_stampsGeohashOnceWindowElapses() async throws { + let context = MockChatNostrContext() + let tracker = GeoPresenceTracker(context: context) + let sender = try NostrIdentity.generate() + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: "sampled activity", + geohash: "9q8yy", + senderIdentity: sender, + nickname: "alice" + ) + + // Pre-seed the same event ID so the timeline append reports a + // duplicate and the flow never reaches the live notification center. + let placeholder = BitchatMessage( + id: event.id, + sender: "seed", + content: "seed", + timestamp: Date(), + isRelay: false + ) + #expect(context.appendGeohashMessageIfAbsent(placeholder, toGeohash: "9q8yy")) + + // Cooldown elapsed: the geohash is re-stamped and the append is + // attempted (and rejected as a duplicate, so no store sync either). + let stale = Date().addingTimeInterval(-TransportConfig.uiGeoNotifyCooldownSeconds - 1) + context.lastGeoNotificationAt["9q8yy"] = stale + tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event) + await drainMainQueue() + + let stamped = try #require(context.lastGeoNotificationAt["9q8yy"]) + #expect(stamped > stale) + #expect(context.appendedGeohashMessages.count == 1) + #expect(context.synchronizedGeohashes.isEmpty) + } +} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 34ef1918..332489a6 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -51,7 +51,7 @@ final class PerformanceBaselineTests: XCTestCase { // MARK: - 1a. Nostr inbound event handling (fresh events) - /// `ChatNostrCoordinator.handleNostrEvent` for never-seen geo events + /// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events /// (kind 20000): signature verification, dedup record, presence/nickname /// bookkeeping, and public-message ingest scheduling. func testNostrInboundEventHandling_freshEvents() throws { @@ -68,7 +68,7 @@ final class PerformanceBaselineTests: XCTestCase { keepAlive.append((context, coordinator)) let start = Date() for event in events { - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) } samples.append(Date().timeIntervalSince(start)) XCTAssertEqual(context.processedEventCount, events.count) @@ -89,7 +89,7 @@ final class PerformanceBaselineTests: XCTestCase { let coordinator = ChatNostrCoordinator(context: context) // Pre-warm: every event is now recorded as processed. for event in events { - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) } XCTAssertEqual(context.processedEventCount, events.count) var samples: [TimeInterval] = [] @@ -97,7 +97,7 @@ final class PerformanceBaselineTests: XCTestCase { measure { let start = Date() for event in events { - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) } samples.append(Date().timeIntervalSince(start)) } @@ -370,8 +370,9 @@ private struct SeededGenerator: RandomNumberGenerator { // MARK: - Mock ChatNostrContext -/// Minimal `ChatNostrContext` for benchmarking `ChatNostrCoordinator` -/// without a `ChatViewModel` (mirrors `ChatNostrCoordinatorContextTests`). +/// Minimal `ChatNostrContext` for benchmarking the `ChatNostrCoordinator` +/// stack (`NostrInboundPipeline` in particular) without a `ChatViewModel` +/// (mirrors `ChatNostrCoordinatorContextTests`). /// Callbacks are cheap dictionary/array operations so the measured cost is /// the coordinator's own pipeline. @MainActor From cc760866154fe159f9ec9537ebb94dfb3ad13e57 Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Jun 2026 23:04:20 +0200 Subject: [PATCH 7/9] Inject notification/favorites singletons through coordinator contexts NotificationService.shared and FavoritesPersistenceService.shared accesses across nine coordinators/components consolidate into ChatViewModel witnesses behind intent-named context members (notifyPrivateMessage, notifyMention, favoriteRelationship(forNoiseKey:), allFavoriteRelationships, postLocalNotification, ...). Singleton reach from coordinators is now zero; nine new tests cover previously untestable notification/favorites flows. Also root-causes the long-flaky gift-wrap dedup test: parallel tests share LocationChannelManager.shared, so channel-switch tests trigger clearProcessedNostrEvents() on every live ChatViewModel, wiping the dedup record mid-test. The invariant (forged-signature copies never poison dedup) now lives as a deterministic NostrInboundPipeline mock-context test; two Schnorr concurrency probes added along the way stay as regression guards for the P256K shared-context assumptions. Co-Authored-By: Claude Fable 5 --- .../ViewModels/ChatLifecycleCoordinator.swift | 11 +- bitchat/ViewModels/ChatNostrCoordinator.swift | 34 ++-- .../ChatPeerIdentityCoordinator.swift | 50 ++++-- .../ViewModels/ChatPeerListCoordinator.swift | 10 +- .../ChatPrivateConversationCoordinator.swift | 55 +++--- .../ChatPublicConversationCoordinator.swift | 10 +- .../ChatVerificationCoordinator.swift | 12 +- bitchat/ViewModels/GeoPresenceTracker.swift | 9 +- bitchat/ViewModels/NostrInboundPipeline.swift | 12 +- ...ChatLifecycleCoordinatorContextTests.swift | 69 +++++++- .../ChatNostrCoordinatorContextTests.swift | 158 +++++++++++++++++- ...tPeerIdentityCoordinatorContextTests.swift | 85 +++++++++- .../ChatPeerListCoordinatorContextTests.swift | 50 +++++- ...eConversationCoordinatorContextTests.swift | 138 ++++++++++++++- ...cConversationCoordinatorContextTests.swift | 64 ++++++- ...tVerificationCoordinatorContextTests.swift | 47 +++++- .../ChatViewModelExtensionsTests.swift | 34 ---- .../PerformanceBaselineTests.swift | 6 + bitchatTests/SchnorrConcurrencyRepro.swift | 82 +++++++++ 19 files changed, 820 insertions(+), 116 deletions(-) create mode 100644 bitchatTests/SchnorrConcurrencyRepro.swift diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index 4e4ad3f3..ddbea7ab 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -53,6 +53,10 @@ protocol ChatLifecycleContext: AnyObject { func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity func recordGeoParticipant(pubkeyHex: String) + // MARK: Favorites (shared with `ChatPrivateConversationContext`) + /// The persisted favorite relationship for the peer's Noise static key, if any. + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? + // MARK: Identity persistence /// Forces the identity manager to persist its state now. func forceSaveIdentity() @@ -69,7 +73,8 @@ extension ChatViewModel: ChatLifecycleContext { // `synchronizePrivateConversationStore()`, `addSystemMessage(_:)`, // `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`, // the routing/ack members, `isTeleported`, - // `deriveNostrIdentity(forGeohash:)`, and `recordGeoParticipant(pubkeyHex:)` + // `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`, + // and `favoriteRelationship(forNoiseKey:)` // are shared requirements with the other contexts or satisfied by // existing `ChatViewModel` members. The members below flatten nested // service accesses into intent-named calls. @@ -192,12 +197,12 @@ final class ChatLifecycleCoordinator { var peerNostrPubkey: String? if let noiseKey = Data(hexString: peerID.id), - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { + let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) { noiseKeyHex = peerID peerNostrPubkey = favoriteStatus.peerNostrPublicKey } else if let peer = context.unifiedPeer(for: peerID) { noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey) + let favoriteStatus = context.favoriteRelationship(forNoiseKey: peer.noisePublicKey) peerNostrPubkey = favoriteStatus?.peerNostrPublicKey if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) { diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index e56ce1db..0b52baa6 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -20,12 +20,23 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) + + // MARK: Favorites & notifications (shared with the other contexts) + /// The persisted favorite relationship for the peer's Noise static key, if any. + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? + /// Adds (or updates) a favorite in the favorites store. + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) + /// Posts a generic local user notification. + func postLocalNotification(title: String, body: String, identifier: String) } extension ChatViewModel: ChatNostrContext { // All requirements โ€” including the component-context witnesses declared // in `GeohashSubscriptionManager.swift`, `NostrInboundPipeline.swift`, - // and `GeoPresenceTracker.swift` โ€” already exist on `ChatViewModel`. + // `GeoPresenceTracker.swift`, and the favorites/notification witnesses in + // `ChatPrivateConversationCoordinator.swift`, + // `ChatPeerIdentityCoordinator.swift`, and + // `ChatVerificationCoordinator.swift` โ€” already exist on `ChatViewModel`. } /// Thin facade over the Nostr stack: owns and wires the three components and @@ -89,16 +100,17 @@ final class ChatNostrCoordinator { @MainActor func handleFavoriteNotification(content: String, from nostrPubkey: String) { + guard let context else { return } guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return } let isFavorite = content.contains("FAVORITE:TRUE") let senderNickname = content.components(separatedBy: "|").last ?? "Unknown" if isFavorite { - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: senderNoiseKey, - peerNostrPublicKey: nostrPubkey, - peerNickname: senderNickname + context.addFavorite( + noiseKey: senderNoiseKey, + nostrPublicKey: nostrPubkey, + nickname: senderNickname ) } @@ -123,14 +135,14 @@ final class ChatNostrCoordinator { "๐Ÿ’พ Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", category: .session ) - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: senderNoiseKey, - peerNostrPublicKey: extractedNostrPubkey, - peerNickname: senderNickname + context.addFavorite( + noiseKey: senderNoiseKey, + nostrPublicKey: extractedNostrPubkey, + nickname: senderNickname ) } - NotificationService.shared.sendLocalNotification( + context.postLocalNotification( title: isFavorite ? "New Favorite" : "Favorite Removed", body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you", identifier: "fav-\(UUID().uuidString)" @@ -140,7 +152,7 @@ final class ChatNostrCoordinator { @MainActor func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { guard let context else { return } - guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey), + guard let relationship = context.favoriteRelationship(forNoiseKey: noisePublicKey), relationship.peerNostrPublicKey != nil else { SecureLogger.warning("โš ๏ธ Cannot send favorite notification - no Nostr key for peer", category: .session) return diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 320f7caf..0d50ea90 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -76,6 +76,16 @@ protocol ChatPeerIdentityContext: AnyObject { func invalidateStoredEncryptionCache(for peerID: PeerID?) func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? + // MARK: Favorites + /// The persisted favorite relationship for the peer's Noise static key, if any. + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? + /// The persisted favorite relationship for a short (ephemeral) peer ID, if any. + func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? + /// Adds (or updates) a favorite in the favorites store. + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) + /// Removes a favorite from the favorites store. + func removeFavorite(noiseKey: Data) + // MARK: Geohash & Nostr var geoNicknames: [String: String] { get } func visibleGeohashPeople() -> [GeoPerson] @@ -183,6 +193,26 @@ extension ChatViewModel: ChatPeerIdentityContext { func bridgedNostrPublicKey(for noiseKey: Data) -> String? { idBridge.getNostrPublicKey(for: noiseKey) } + + // `favoriteRelationship(forNoiseKey:)` is shared with + // `ChatPrivateConversationContext`; its witness lives in + // `ChatPrivateConversationCoordinator.swift`. + + func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? { + FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID) + } + + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) { + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrPublicKey, + peerNickname: nickname + ) + } + + func removeFavorite(noiseKey: Data) { + FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) + } } final class ChatPeerIdentityCoordinator { @@ -255,7 +285,7 @@ final class ChatPeerIdentityCoordinator { @MainActor func isFavorite(peerID: PeerID) -> Bool { if let noisePublicKey = peerID.noiseKey { - return FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)?.isFavorite ?? false + return context.favoriteRelationship(forNoiseKey: noisePublicKey)?.isFavorite ?? false } return context.unifiedPeer(for: peerID)?.isFavorite ?? false @@ -499,12 +529,12 @@ final class ChatPeerIdentityCoordinator { if let name = context.peerNickname(for: peerID) { return name } - if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), + if let favorite = context.favoriteRelationship(forPeerID: peerID), !favorite.peerNickname.isEmpty { return favorite.peerNickname } if let noiseKey = Data(hexString: peerID.id), - let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + let favorite = context.favoriteRelationship(forNoiseKey: noiseKey), !favorite.peerNickname.isEmpty { return favorite.peerNickname } @@ -579,7 +609,7 @@ private extension ChatPeerIdentityCoordinator { if let nickname = context.peerNickname(for: peerID) { return nickname } - if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) { + if let favorite = context.favoriteRelationship(forNoiseKey: peerPublicKey) { return favorite.peerNickname } return "Unknown" @@ -602,7 +632,7 @@ private extension ChatPeerIdentityCoordinator { return } - let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) + let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey) let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender let plan = ChatFavoriteTogglePolicy.plan( currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init), @@ -612,14 +642,14 @@ private extension ChatPeerIdentityCoordinator { switch plan.persistenceAction { case .add(let nickname, let nostrKey): - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: noisePublicKey, - peerNostrPublicKey: nostrKey, - peerNickname: nickname + context.addFavorite( + noiseKey: noisePublicKey, + nostrPublicKey: nostrKey, + nickname: nickname ) case .remove: - FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) + context.removeFavorite(noiseKey: noisePublicKey) } context.notifyUIChanged() diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index f2fff0af..71145fe0 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -28,6 +28,10 @@ protocol ChatPeerListContext: AnyObject { func activeMeshPeerCount() -> Int func registerEphemeralSession(peerID: PeerID) func updateEncryptionStatusForPeers() + + // MARK: Notifications + /// Posts the "bitchatters nearby" local notification. + func notifyNetworkAvailable(peerCount: Int) } extension ChatViewModel: ChatPeerListContext { @@ -48,6 +52,10 @@ extension ChatViewModel: ChatPeerListContext { } .count } + + func notifyNetworkAvailable(peerCount: Int) { + NotificationService.shared.sendNetworkAvailableNotification(peerCount: peerCount) + } } final class ChatPeerListCoordinator: @unchecked Sendable { @@ -115,7 +123,7 @@ private extension ChatPeerListCoordinator { if Date().timeIntervalSince(lastNetworkNotificationTime) >= cooldown { recentlySeenPeers.formUnion(newPeers) lastNetworkNotificationTime = Date() - NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) + context.notifyNetworkAvailable(peerCount: meshPeers.count) SecureLogger.info( "๐Ÿ‘ฅ Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", category: .session diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index ea1d28e0..2ae4532c 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -69,6 +69,14 @@ protocol ChatPrivateConversationContext: AnyObject { func addSystemMessage(_ content: String) func addMeshOnlySystemMessage(_ content: String) func sanitizeChat(for peerID: PeerID) + + // MARK: Favorites & notifications + /// The persisted favorite relationship for the peer's Noise static key, if any. + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? + /// Persists that the peer favorited/unfavorited us (favorites store write). + func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) + /// Posts the incoming-private-message local notification. + func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) } extension ChatViewModel: ChatPrivateConversationContext { @@ -161,6 +169,23 @@ extension ChatViewModel: ChatPrivateConversationContext { privateChatManager.sanitizeChat(for: peerID) } + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { + FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) + } + + func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { + FavoritesPersistenceService.shared.updatePeerFavoritedUs( + peerNoisePublicKey: noiseKey, + favorited: favorited, + peerNickname: nickname, + peerNostrPublicKey: nostrPublicKey + ) + } + + func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) { + NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID) + } + private func makeGeohashNostrTransport() -> NostrTransport { let transport = NostrTransport(keychain: keychain, idBridge: idBridge) transport.senderPeerID = meshService.myPeerID @@ -199,7 +224,7 @@ final class ChatPrivateConversationCoordinator { guard let noiseKey = Data(hexString: peerID.id) else { return } let isConnected = context.isPeerConnected(peerID) let isReachable = context.isPeerReachable(peerID) - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) + let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) let isMutualFavorite = favoriteStatus?.isMutual ?? false let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil @@ -393,11 +418,7 @@ final class ChatPrivateConversationCoordinator { } if !isViewing && shouldMarkUnread { - NotificationService.shared.sendPrivateMessageNotification( - from: senderName, - message: pm.content, - peerID: convKey - ) + context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey) } context.notifyUIChanged() @@ -592,11 +613,7 @@ final class ChatPrivateConversationCoordinator { context.markReadReceiptSent(message.id) } else { context.unreadPrivateMessages.insert(peerID) - NotificationService.shared.sendPrivateMessageNotification( - from: message.sender, - message: message.content, - peerID: peerID - ) + context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID) } context.notifyUIChanged() @@ -692,11 +709,7 @@ final class ChatPrivateConversationCoordinator { context.unreadPrivateMessages.insert(ephemeralPeerID) } if isRecentMessage { - NotificationService.shared.sendPrivateMessageNotification( - from: senderNickname, - message: messageContent, - peerID: targetPeerID - ) + context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID) } } @@ -716,12 +729,12 @@ final class ChatPrivateConversationCoordinator { return } - let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false - FavoritesPersistenceService.shared.updatePeerFavoritedUs( - peerNoisePublicKey: finalNoiseKey, + let prior = context.favoriteRelationship(forNoiseKey: finalNoiseKey)?.theyFavoritedUs ?? false + context.updatePeerFavoritedUs( + noiseKey: finalNoiseKey, favorited: isFavorite, - peerNickname: senderNickname, - peerNostrPublicKey: nostrPubkey + nickname: senderNickname, + nostrPublicKey: nostrPubkey ) if isFavorite && nostrPubkey != nil { diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index c7627ca3..41ae4ffd 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -88,6 +88,10 @@ protocol ChatPublicConversationContext: AnyObject { func recordContentKey(_ key: String, timestamp: Date) /// Pre-renders the message so the formatting cache is warm before display. func prewarmMessageFormatting(_ message: BitchatMessage) + + // MARK: Notifications + /// Posts the you-were-mentioned local notification. + func notifyMention(from sender: String, message: String) } extension ChatViewModel: ChatPublicConversationContext { @@ -184,6 +188,10 @@ extension ChatViewModel: ChatPublicConversationContext { func prewarmMessageFormatting(_ message: BitchatMessage) { _ = formatMessageAsText(message, colorScheme: currentColorScheme) } + + func notifyMention(from sender: String, message: String) { + NotificationService.shared.sendMentionNotification(from: sender, message: message) + } } @MainActor @@ -548,7 +556,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { if isMentioned && message.sender != context.nickname { SecureLogger.info("๐Ÿ”” Mention from \(message.sender)", category: .session) - NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) + context.notifyMention(from: message.sender, message: message.content) } } diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index b61aec2f..356fbd7f 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -56,6 +56,10 @@ protocol ChatVerificationContext: AnyObject { func triggerHandshake(with peerID: PeerID) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + + // MARK: Notifications (shared with `ChatNostrContext`) + /// Posts a generic local user notification. + func postLocalNotification(title: String, body: String, identifier: String) } extension ChatViewModel: ChatVerificationContext { @@ -110,6 +114,10 @@ extension ChatViewModel: ChatVerificationContext { func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) } + + func postLocalNotification(title: String, body: String, identifier: String) { + NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: identifier) + } } @MainActor @@ -313,7 +321,7 @@ final class ChatVerificationCoordinator { let peerName = context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID) - NotificationService.shared.sendLocalNotification( + context.postLocalNotification( title: "Verified", body: "You verified \(peerName)", identifier: "verify-success-\(peerID)-\(UUID().uuidString)" @@ -347,7 +355,7 @@ private extension ChatVerificationCoordinator { guard now.timeIntervalSince(lastToast) > 60 else { return } lastMutualToastAt[fingerprint] = now - NotificationService.shared.sendLocalNotification( + context.postLocalNotification( title: title, body: "You and \(bodyName) verified each other", identifier: "\(notificationPrefix)-\(peerID)-\(UUID().uuidString)" diff --git a/bitchat/ViewModels/GeoPresenceTracker.swift b/bitchat/ViewModels/GeoPresenceTracker.swift index 587982c3..100041a9 100644 --- a/bitchat/ViewModels/GeoPresenceTracker.swift +++ b/bitchat/ViewModels/GeoPresenceTracker.swift @@ -25,6 +25,9 @@ protocol GeoPresenceContext: AnyObject { func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool func synchronizePublicConversationStore(forGeohash geohash: String) + + /// Posts the sampled-geohash-activity local notification. + func notifyGeohashActivity(geohash: String, bodyPreview: String) } extension ChatViewModel: GeoPresenceContext { @@ -52,6 +55,10 @@ extension ChatViewModel: GeoPresenceContext { func markGeoTeleported(_ pubkeyHexLowercased: String) { locationPresenceStore.markTeleported(pubkeyHexLowercased) } + + func notifyGeohashActivity(geohash: String, bodyPreview: String) { + NotificationService.shared.sendGeohashActivityNotification(geohash: geohash, bodyPreview: bodyPreview) + } } /// Geohash presence bookkeeping that is independent of relay subscriptions: @@ -160,7 +167,7 @@ final class GeoPresenceTracker { ) if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) { context.synchronizePublicConversationStore(forGeohash: gh) - NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview) + context.notifyGeohashActivity(geohash: gh, bodyPreview: preview) } } } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index a1d4b4d5..95d9b234 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -20,6 +20,11 @@ protocol NostrInboundPipelineContext: AnyObject { func isNostrBlocked(pubkeyHexLowercased: String) -> Bool func displayNameForNostrPubkey(_ pubkeyHex: String) -> String + // MARK: Favorites bridge + /// All favorite relationships, used to bridge a Nostr pubkey back to a + /// Noise key on the inbound DM path. + func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] + // MARK: Presence & key mapping func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) /// Records the Nostr pubkey behind a (possibly virtual) peer ID. @@ -53,6 +58,10 @@ extension ChatViewModel: NostrInboundPipelineContext { deduplicationService.hasProcessedNostrEvent(eventID) } + func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { + Array(FavoritesPersistenceService.shared.favorites.values) + } + func recordProcessedNostrEvent(_ eventID: String) { deduplicationService.recordNostrEvent(eventID) } @@ -437,7 +446,8 @@ final class NostrInboundPipeline { /// the favorites glue in `ChatNostrCoordinator` delegates to it. @MainActor func findNoiseKey(for nostrPubkey: String) -> Data? { - let favorites = FavoritesPersistenceService.shared.favorites.values + guard let context else { return nil } + let favorites = context.allFavoriteRelationships() var npubToMatch = nostrPubkey if !nostrPubkey.hasPrefix("npub") { diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index 12693601..ca3afee8 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -7,12 +7,12 @@ // `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: the mesh/Nostr read-receipt branch of -// `markPrivateMessagesAsRead` consults `FavoritesPersistenceService.shared` -// and the geohash-screenshot branch publishes via `NostrRelayManager.shared` / -// `GeoRelayDirectory.shared`; those stay covered by the full view-model tests. -// The GeoDM read pass, message merging, screenshot notices, and lifecycle -// persistence flows are covered here. +// Scope note: the geohash-screenshot branch publishes via +// `NostrRelayManager.shared` / `GeoRelayDirectory.shared`; that stays covered +// by the full view-model tests. The GeoDM read pass, the favorites-backed +// mesh/Nostr read-receipt branch (favorites are injected through the +// context), message merging, screenshot notices, and lifecycle persistence +// flows are covered here. // import Testing @@ -101,6 +101,13 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity } func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) } + // Favorites + var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] + + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByNoiseKey[noiseKey] + } + // Identity persistence private(set) var forceSaveIdentityCount = 0 private(set) var verifyIdentityKeyExistsCount = 0 @@ -123,6 +130,24 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { // MARK: - Helpers +private func makeFavoriteRelationship( + noiseKey: Data, + nostrPublicKey: String? = nil, + nickname: String = "alice", + isFavorite: Bool = false, + theyFavoritedUs: Bool = false +) -> FavoritesPersistenceService.FavoriteRelationship { + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrPublicKey, + peerNickname: nickname, + isFavorite: isFavorite, + theyFavoritedUs: theyFavoritedUs, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) +} + @MainActor private func makePrivateMessage( id: String, @@ -280,4 +305,36 @@ struct ChatLifecycleCoordinatorContextTests { } #expect(context.ownerLevelReadPasses == [peerID]) } + @Test @MainActor + func markPrivateMessagesAsRead_routesReceiptsOnlyForNostrReachableFavorites() { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + let noiseKey = Data(repeating: 0xAB, count: 32) + let peerID = PeerID(hexData: noiseKey) + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + nostrPublicKey: "npub1alice" + ) + context.privateChats[peerID] = [ + makePrivateMessage(id: "in-1", senderPeerID: peerID), + makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true), + ] + + coordinator.markPrivateMessagesAsRead(from: peerID) + + // Favorite with a Nostr key: READ receipts routed for non-relay + // inbound messages and recorded as sent. + #expect(context.managerReadMarks == [peerID]) + #expect(context.routedReadReceipts.map(\.messageID) == ["in-1"]) + #expect(context.routedReadReceipts.map(\.peerID) == [peerID]) + #expect(context.sentReadReceipts.contains("in-1")) + + // No favorite relationship (no Nostr key): the receipt pass is skipped. + let otherKey = Data(repeating: 0xCD, count: 32) + let otherPeer = PeerID(hexData: otherKey) + context.privateChats[otherPeer] = [makePrivateMessage(id: "in-2", senderPeerID: otherPeer)] + coordinator.markPrivateMessagesAsRead(from: otherPeer) + #expect(context.routedReadReceipts.map(\.messageID) == ["in-1"]) + } + } diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 795d6e8e..d9d68c4c 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -215,6 +215,32 @@ private final class MockChatNostrContext: ChatNostrContext { func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { geoReadReceipts.append((messageID, recipientHex)) } + + // Favorites & notifications + var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] + private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = [] + private(set) var postedLocalNotifications: [(title: String, body: String, identifier: String)] = [] + private(set) var geohashActivityNotifications: [(geohash: String, bodyPreview: String)] = [] + + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByNoiseKey[noiseKey] + } + + func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { + Array(favoriteRelationshipsByNoiseKey.values) + } + + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) { + addedFavorites.append((noiseKey, nostrPublicKey, nickname)) + } + + func postLocalNotification(title: String, body: String, identifier: String) { + postedLocalNotifications.append((title, body, identifier)) + } + + func notifyGeohashActivity(geohash: String, bodyPreview: String) { + geohashActivityNotifications.append((geohash, bodyPreview)) + } } // MARK: - Helpers @@ -228,14 +254,34 @@ private func drainMainQueue() async { } } +private func makeFavoriteRelationship( + noiseKey: Data, + nostrPublicKey: String? = nil, + nickname: String = "alice", + isFavorite: Bool = false, + theyFavoritedUs: Bool = false +) -> FavoritesPersistenceService.FavoriteRelationship { + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrPublicKey, + peerNickname: nickname, + isFavorite: isFavorite, + theyFavoritedUs: theyFavoritedUs, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) +} + // MARK: - Coordinator Tests Against Mock Context /// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no /// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence, /// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch -/// teardown, and embedded ack flows. Flows that hit live singletons -/// (`NostrRelayManager.shared` subscriptions, `TorManager`, -/// `FavoritesPersistenceService`) remain covered by the full view-model tests. +/// teardown, embedded ack flows, and โ€” now that favorites and notifications +/// are injected through the context โ€” the favorite-notification ingest and +/// the sampled-geohash notification cooldown. Flows that hit live singletons +/// (`NostrRelayManager.shared` subscriptions, `TorManager`) remain covered by +/// the full view-model tests. struct ChatNostrCoordinatorContextTests { @Test @MainActor @@ -361,6 +407,30 @@ struct ChatNostrCoordinatorContextTests { #expect(context.handledPrivateMessages.count == 1) } + @Test @MainActor + func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + + let recipient = try NostrIdentity.generate() + let sender = try NostrIdentity.generate() + let giftWrap = try NostrProtocol.createPrivateMessage( + content: "verify:noop", + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + var invalidGiftWrap = giftWrap + invalidGiftWrap.sig = String(repeating: "0", count: 128) + + // A forged-signature copy is rejected WITHOUT entering the dedup set... + await coordinator.inbound.processNostrMessage(invalidGiftWrap) + #expect(context.recordedNostrEventIDs.isEmpty) + + // ...so the genuine event with the same ID still processes and records. + await coordinator.inbound.processNostrMessage(giftWrap) + #expect(context.recordedNostrEventIDs == [giftWrap.id]) + } + @Test @MainActor func switchLocationChannel_toMesh_tearsDownGeohashState() async { let context = MockChatNostrContext() @@ -546,4 +616,86 @@ struct GeoPresenceTrackerTests { #expect(context.appendedGeohashMessages.count == 1) #expect(context.synchronizedGeohashes.isEmpty) } + @Test @MainActor + func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + let sender = try NostrIdentity.generate() + let noiseKey = Data(repeating: 0x42, count: 32) + // The favorites store bridges the sender's npub back to a Noise key. + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + nostrPublicKey: sender.npub + ) + + coordinator.handleFavoriteNotification(content: "FAVORITE:TRUE|alice", from: sender.publicKeyHex) + + #expect(context.addedFavorites.count == 1) + #expect(context.addedFavorites.first?.noiseKey == noiseKey) + #expect(context.addedFavorites.first?.nostrPublicKey == sender.publicKeyHex) + #expect(context.addedFavorites.first?.nickname == "alice") + #expect(context.postedLocalNotifications.count == 1) + #expect(context.postedLocalNotifications.first?.title == "New Favorite") + #expect(context.postedLocalNotifications.first?.body == "alice favorited you") + + // Unfavorite: no store write, but the removal notification still posts. + coordinator.handleFavoriteNotification(content: "FAVORITE:FALSE|alice", from: sender.publicKeyHex) + #expect(context.addedFavorites.count == 1) + #expect(context.postedLocalNotifications.last?.title == "Favorite Removed") + #expect(context.postedLocalNotifications.last?.body == "alice unfavorited you") + } + + @Test @MainActor + func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + let sender = try NostrIdentity.generate() + context.geoNicknames[sender.publicKeyHex.lowercased()] = "alice" + + let first = try NostrProtocol.createEphemeralGeohashEvent( + content: "hello geohash", + geohash: "u4pruyd", + senderIdentity: sender, + nickname: "alice" + ) + coordinator.presence.cooldownPerGeohash("u4pruyd", content: "hello geohash", event: first) + await drainMainQueue() + + // Sampled message recorded, store synced, and notification posted. + #expect(context.appendedGeohashMessages.map(\.message.id) == [first.id]) + #expect(context.appendedGeohashMessages.first?.message.sender == "alice#" + String(first.pubkey.suffix(4))) + #expect(context.synchronizedGeohashes == ["u4pruyd"]) + #expect(context.geohashActivityNotifications.count == 1) + #expect(context.geohashActivityNotifications.first?.geohash == "u4pruyd") + #expect(context.geohashActivityNotifications.first?.bodyPreview == "hello geohash") + #expect(context.lastGeoNotificationAt["u4pruyd"] != nil) + + // A second sampled event inside the cooldown window is fully suppressed. + let second = try NostrProtocol.createEphemeralGeohashEvent( + content: "again", + geohash: "u4pruyd", + senderIdentity: sender, + nickname: "alice" + ) + coordinator.presence.cooldownPerGeohash("u4pruyd", content: "again", event: second) + await drainMainQueue() + #expect(context.geohashActivityNotifications.count == 1) + #expect(context.appendedGeohashMessages.count == 1) + + // Long previews are truncated to the snippet cap with an ellipsis. + let longContent = String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen + 20) + let third = try NostrProtocol.createEphemeralGeohashEvent( + content: longContent, + geohash: "9q8yyk", + senderIdentity: sender, + nickname: "alice" + ) + coordinator.presence.cooldownPerGeohash("9q8yyk", content: longContent, event: third) + await drainMainQueue() + #expect( + context.geohashActivityNotifications.last?.bodyPreview + == String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen) + "โ€ฆ" + ) + } + } diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index bf0cbddb..e162b77d 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -7,11 +7,10 @@ // `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: flows that hit the `FavoritesPersistenceService.shared` -// singleton (`isFavorite` / `toggleFavoriteForNoiseKey` / favorite -// notifications / `nicknameForPeer` fallbacks) remain covered by the full -// view-model tests; the session, migration, encryption-status, and nickname -// resolution flows are covered here. +// Scope note: favorites are injected through the context +// (`favoriteRelationship(forNoiseKey:)` / `addFavorite` / `removeFavorite`), +// so the favorite toggle and lookup flows are covered here alongside the +// session, migration, encryption-status, and nickname resolution flows. // import Testing @@ -171,10 +170,50 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { nostrFavoriteNotifications.append((noisePublicKey, isFavorite)) } + + // Favorites + var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] + var favoriteRelationshipsByPeerID: [PeerID: FavoritesPersistenceService.FavoriteRelationship] = [:] + private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = [] + private(set) var removedFavorites: [Data] = [] + + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByNoiseKey[noiseKey] + } + + func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByPeerID[peerID] + } + + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) { + addedFavorites.append((noiseKey, nostrPublicKey, nickname)) + } + + func removeFavorite(noiseKey: Data) { + removedFavorites.append(noiseKey) + } } // MARK: - Helpers +private func makeFavoriteRelationship( + noiseKey: Data, + nostrPublicKey: String? = nil, + nickname: String = "alice", + isFavorite: Bool = false, + theyFavoritedUs: Bool = false +) -> FavoritesPersistenceService.FavoriteRelationship { + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrPublicKey, + peerNickname: nickname, + isFavorite: isFavorite, + theyFavoritedUs: theyFavoritedUs, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) +} + @MainActor private func makePrivateMessage( id: String, @@ -355,4 +394,40 @@ struct ChatPeerIdentityCoordinatorContextTests { context.peerIDsByNickname["carol"] = meshPeer #expect(coordinator.getPeerIDForNickname("carol") == meshPeer) } + @Test @MainActor + func toggleFavorite_forNoiseKeyPeer_usesInjectedFavoritesStore() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let noiseKey = Data(repeating: 0xAB, count: 32) + let peerID = PeerID(hexData: noiseKey) + + // No prior relationship: adds a favorite, no Nostr notification yet. + coordinator.toggleFavorite(peerID: peerID) + #expect(context.addedFavorites.count == 1) + #expect(context.addedFavorites.first?.noiseKey == noiseKey) + #expect(context.addedFavorites.first?.nickname == "Unknown") + #expect(context.nostrFavoriteNotifications.isEmpty) + #expect(coordinator.isFavorite(peerID: peerID) == false) + + // They already favorite us: adding sends the mutual notification. + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + theyFavoritedUs: true + ) + coordinator.toggleFavorite(peerID: peerID) + #expect(context.addedFavorites.count == 2) + #expect(context.addedFavorites.last?.nickname == "alice") + #expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true]) + + // Existing favorite: toggling removes it and notifies the unfavorite. + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + isFavorite: true + ) + #expect(coordinator.isFavorite(peerID: peerID) == true) + coordinator.toggleFavorite(peerID: peerID) + #expect(context.removedFavorites == [noiseKey]) + #expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true, false]) + } + } diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index 85726978..67e433f5 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -7,10 +7,9 @@ // `ChatDeliveryCoordinatorContextTests` / // `ChatTransportEventCoordinatorContextTests` exemplars. // -// Scope note: the network-availability notification path posts through -// `NotificationService.shared` (a singleton) and arms wall-clock timers. These -// tests keep every peer mesh-inactive (`isPeerConnected`/`isPeerReachable` -// both false) so that path is never reached; the timer-driven reset flows are +// Scope note: the network-availability notification now posts through the +// injected `ChatPeerListContext` (`notifyNetworkAvailable(peerCount:)`), so +// its gating is covered here; the wall-clock timer-driven reset flows are // covered by integration-level tests. // @@ -54,6 +53,13 @@ private final class MockChatPeerListContext: ChatPeerListContext { func activeMeshPeerCount() -> Int { activeMeshPeerCountValue } func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) } func updateEncryptionStatusForPeers() { updateEncryptionStatusForPeersCount += 1 } + + // Notifications + private(set) var networkAvailableNotifications: [Int] = [] + + func notifyNetworkAvailable(peerCount: Int) { + networkAvailableNotifications.append(peerCount) + } } // MARK: - Helpers @@ -162,4 +168,40 @@ struct ChatPeerListCoordinatorContextTests { #expect(context.unreadPrivateMessages == [currentPeer, geoDMWithMessages, noiseKeyWithMessages]) #expect(context.cleanupOldReadReceiptsCount == 1) } + + @Test @MainActor + func didUpdatePeerList_notifiesNetworkAvailableOncePerCooldownForNewMeshPeers() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + let peerA = PeerID(str: "0011223344556677") + let peerB = PeerID(str: "8899aabbccddeeff") + context.connectedMeshPeers = [peerA, peerB] + + // First sighting of a mesh-active peer notifies with the mesh peer count. + coordinator.didUpdatePeerList([peerA]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + + // The same peer again is not new โ€” no repeat notification. + coordinator.didUpdatePeerList([peerA]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + + // A genuinely new peer inside the cooldown window stays silent too. + coordinator.didUpdatePeerList([peerA, peerB]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + } + + @Test @MainActor + func didUpdatePeerList_meshInactivePeersNeverNotify() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + let peerA = PeerID(str: "0011223344556677") + + // Peer present but neither connected nor reachable: no notification. + coordinator.didUpdatePeerList([peerA]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications.isEmpty) + } } diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index e8f9493e..7a6fcf67 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -132,6 +132,23 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC embeddedDeliveryAckMessageIDs.append(message.id) } + // Favorites & notifications + var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] + private(set) var peerFavoritedUsUpdates: [(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)] = [] + private(set) var privateMessageNotifications: [(senderName: String, message: String, peerID: PeerID)] = [] + + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByNoiseKey[noiseKey] + } + + func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { + peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey)) + } + + func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) { + privateMessageNotifications.append((senderName, message, peerID)) + } + // System messages & chat hygiene private(set) var systemMessages: [String] = [] private(set) var meshOnlySystemMessages: [String] = [] @@ -191,13 +208,33 @@ private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool { return false } +private func makeFavoriteRelationship( + noiseKey: Data, + nostrPublicKey: String? = nil, + nickname: String = "alice", + isFavorite: Bool = false, + theyFavoritedUs: Bool = false +) -> FavoritesPersistenceService.FavoriteRelationship { + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrPublicKey, + peerNickname: nickname, + isFavorite: isFavorite, + theyFavoritedUs: theyFavoritedUs, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) +} + // MARK: - Coordinator Tests Against Mock Context /// Exercises `ChatPrivateConversationCoordinator` against /// `MockChatPrivateConversationContext` with no `ChatViewModel`. Scoped to the -/// pure-state and ack flows; flows that hit `NotificationService` / -/// `FavoritesPersistenceService` singletons remain covered by the full -/// view-model tests. +/// pure-state and ack flows plus โ€” now that notifications and favorites are +/// injected through the context (`notifyPrivateMessage`, +/// `favoriteRelationship(forNoiseKey:)`, `updatePeerFavoritedUs`) โ€” the +/// notification and favorite-transition flows that previously required the +/// live singletons. struct ChatPrivateConversationCoordinatorContextTests { @Test @MainActor @@ -402,4 +439,99 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.sanitizedPeerIDs == [newPeerID]) #expect(context.notifyUIChangedCount == 1) } + + @Test @MainActor + func handlePrivateMessage_postsNotificationOnlyWhenNotViewingChat() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let peerID = PeerID(str: "0102030405060708") + + // Not viewing: marked unread and a local notification is posted. + coordinator.handlePrivateMessage( + makeIncomingMessage(id: "pm-1", content: "hi there", senderPeerID: peerID) + ) + #expect(context.unreadPrivateMessages == [peerID]) + #expect(context.privateMessageNotifications.count == 1) + #expect(context.privateMessageNotifications.first?.senderName == "alice") + #expect(context.privateMessageNotifications.first?.message == "hi there") + #expect(context.privateMessageNotifications.first?.peerID == peerID) + #expect(context.meshReadReceipts.isEmpty) + + // Viewing the chat: a READ ack is sent instead and no notification fires. + context.selectedPrivateChatPeer = peerID + coordinator.handlePrivateMessage(makeIncomingMessage(id: "pm-2", senderPeerID: peerID)) + #expect(context.meshReadReceipts.map(\.messageID) == ["pm-2"]) + #expect(context.sentReadReceipts.contains("pm-2")) + #expect(context.privateMessageNotifications.count == 1) + } + + @Test @MainActor + func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xAB, count: 32) + let peerID = PeerID(hexData: noiseKey) + + // First [FAVORITED] flips theyFavoritedUs: store write + announcement. + coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") + #expect(context.peerFavoritedUsUpdates.count == 1) + #expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey) + #expect(context.peerFavoritedUsUpdates.first?.favorited == true) + #expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice") + #expect(context.meshOnlySystemMessages == ["alice favorited you"]) + + // Same state again: store write still happens, but no repeat announcement. + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + theyFavoritedUs: true + ) + coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") + #expect(context.peerFavoritedUsUpdates.count == 2) + #expect(context.meshOnlySystemMessages == ["alice favorited you"]) + + // [UNFAVORITED] transition announces again. + coordinator.handleFavoriteNotificationFromMesh("[UNFAVORITED]", from: peerID, senderNickname: "alice") + #expect(context.peerFavoritedUsUpdates.last?.favorited == false) + #expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"]) + } + + @Test @MainActor + func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xCD, count: 32) + let peerID = PeerID(hexData: noiseKey) + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + nostrPublicKey: "npub1bob", + nickname: "bob", + isFavorite: true, + theyFavoritedUs: true + ) + + coordinator.sendPrivateMessage("hello bob", to: peerID) + + // Offline but mutual favorite with a Nostr key: routed, marked sent, + // and the nickname falls back to the favorite relationship. + #expect(context.routedPrivateMessages.map(\.content) == ["hello bob"]) + #expect(context.privateChats[peerID]?.first?.deliveryStatus == .sent) + #expect(context.privateChats[peerID]?.first?.recipientNickname == "bob") + #expect(context.systemMessages.isEmpty) + } + + @Test @MainActor + func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let peerID = PeerID(hexData: Data(repeating: 0xCD, count: 32)) + + coordinator.sendPrivateMessage("hello?", to: peerID) + + #expect(context.routedPrivateMessages.isEmpty) + #expect(context.systemMessages.count == 1) + guard case .failed = context.privateChats[peerID]?.first?.deliveryStatus else { + Issue.record("expected .failed delivery status") + return + } + } } diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index 6051ccca..fb661241 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -7,11 +7,11 @@ // `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: flows that hit process-wide singletons are intentionally not -// exercised here โ€” `checkForMentions` / haptics (NotificationService.shared, -// UIApplication) and the geohash branch of `sendPublicRaw` -// (NostrRelayManager.shared, GeoRelayDirectory.shared). The mesh branch of -// `sendPublicRaw` and all timeline/store/blocking flows are covered. +// Scope note: haptics (UIApplication) and the geohash branch of +// `sendPublicRaw` (NostrRelayManager.shared, GeoRelayDirectory.shared) are +// intentionally not exercised here. `checkForMentions` posts through the +// injected context (`notifyMention(from:message:)`) and is covered, as are +// the mesh branch of `sendPublicRaw` and all timeline/store/blocking flows. // import Testing @@ -249,6 +249,13 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon prewarmedMessageIDs.append(message.id) } + // Notifications + private(set) var mentionNotifications: [(sender: String, message: String)] = [] + + func notifyMention(from sender: String, message: String) { + mentionNotifications.append((sender, message)) + } + static let dummyIdentity = NostrIdentity( privateKey: Data(repeating: 0x11, count: 32), publicKey: Data(repeating: 0x22, count: 32), @@ -502,4 +509,51 @@ struct ChatPublicConversationCoordinatorContextTests { let unknownHex = String(repeating: "12", count: 32) #expect(coordinator.displayNameForNostrPubkey(unknownHex) == "anon#" + unknownHex.suffix(4)) } + @Test @MainActor + func checkForMentions_postsMentionNotificationOnlyForOthersMentioningMe() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + + // A mention of my nickname from someone else notifies. + coordinator.checkForMentions( + BitchatMessage( + id: "mention-1", + sender: "alice", + content: "hey @me", + timestamp: Date(), + isRelay: false, + mentions: ["me"] + ) + ) + #expect(context.mentionNotifications.count == 1) + #expect(context.mentionNotifications.first?.sender == "alice") + #expect(context.mentionNotifications.first?.message == "hey @me") + + // Mentioning someone else does not notify. + coordinator.checkForMentions( + BitchatMessage( + id: "mention-2", + sender: "alice", + content: "hey @bob", + timestamp: Date(), + isRelay: false, + mentions: ["bob"] + ) + ) + #expect(context.mentionNotifications.count == 1) + + // My own message mentioning myself does not notify. + coordinator.checkForMentions( + BitchatMessage( + id: "mention-3", + sender: "me", + content: "talking about @me", + timestamp: Date(), + isRelay: false, + mentions: ["me"] + ) + ) + #expect(context.mentionNotifications.count == 1) + } + } diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift index 8dff14c3..4ef7f9f7 100644 --- a/bitchatTests/ChatVerificationCoordinatorContextTests.swift +++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift @@ -7,11 +7,12 @@ // `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: `handleVerifyResponsePayload` requires a real Ed25519 signature -// and posts via `NotificationService.shared`; it remains covered by the full -// view-model/integration tests. Challenge handling, QR kickoff, fingerprint -// verification, and verified-set loading are covered here -// (`VerificationService.shared` is only used for pure payload build/parse). +// Scope note: `handleVerifyResponsePayload` requires a real Ed25519 +// signature; it remains covered by the full view-model/integration tests. +// Challenge handling, QR kickoff, fingerprint verification, verified-set +// loading, and the mutual-verification notification (posted through the +// injected context) are covered here (`VerificationService.shared` is only +// used for pure payload build/parse). // import Testing @@ -117,6 +118,13 @@ private final class MockChatVerificationContext: ChatVerificationContext { func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { sentResponses.append((peerID, noiseKeyHex, nonceA)) } + + // Notifications + private(set) var postedLocalNotifications: [(title: String, body: String, identifier: String)] = [] + + func postLocalNotification(title: String, body: String, identifier: String) { + postedLocalNotifications.append((title, body, identifier)) + } } // MARK: - Helpers @@ -272,6 +280,35 @@ struct ChatVerificationCoordinatorContextTests { await waitForMainQueue() #expect(context.encryptionStatuses[peerID] == .noiseHandshaking) } + + @Test @MainActor + func handleVerifyChallengePayload_postsMutualVerificationToastOncePerMinute() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let myHex = context.myNoiseStaticKey.hexEncodedString() + context.fingerprintsByPeerID[peerID] = "fp-mutual" + context.verifiedFingerprints = ["fp-mutual"] + + coordinator.handleVerifyChallengePayload( + from: peerID, + payload: makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: Data(repeating: 0x07, count: 16)) + ) + + // Already-verified peer challenging us: mutual-verification toast. + #expect(context.postedLocalNotifications.count == 1) + #expect(context.postedLocalNotifications.first?.title == "Mutual verification") + #expect(context.postedLocalNotifications.first?.body.hasSuffix("verified each other") == true) + #expect(context.postedLocalNotifications.first?.identifier.hasPrefix("verify-mutual-") == true) + + // A fresh nonce inside the per-fingerprint toast cooldown stays silent. + coordinator.handleVerifyChallengePayload( + from: peerID, + payload: makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: Data(repeating: 0x08, count: 16)) + ) + #expect(context.postedLocalNotifications.count == 1) + #expect(context.sentResponses.count == 2) + } } /// The installed callbacks hop through `DispatchQueue.main.async`; tests must diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index c4875321..b275358c 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -398,40 +398,6 @@ struct ChatViewModelNostrExtensionTests { #expect(viewModel.privateChats.isEmpty) } - @Test @MainActor - func handleNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws { - let (viewModel, _) = makeTestableViewModel() - let sender = try NostrIdentity.generate() - let recipient = try #require(try viewModel.idBridge.getCurrentNostrIdentity()) - let giftWrap = try NostrProtocol.createPrivateMessage( - content: "verify:noop", - recipientPubkey: recipient.publicKeyHex, - senderIdentity: sender - ) - var invalidGiftWrap = giftWrap - invalidGiftWrap.sig = String(repeating: "0", count: 128) - - // Verification and recording now happen on a detached task; give the - // invalid copy time to be verified-and-rejected, then assert it never - // entered the dedup set. - viewModel.handleNostrMessage(invalidGiftWrap) - try await Task.sleep(nanoseconds: 150_000_000) - #expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id)) - - // Generous deadline: the detached verification task can be starved - // under parallel test load. - viewModel.handleNostrMessage(giftWrap) - var recorded = false - for _ in 0..<600 { - if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { - recorded = true - break - } - try await Task.sleep(nanoseconds: 10_000_000) - } - #expect(recorded) - } - @Test @MainActor func switchLocationChannel_clearsNostrDedupCache() async { let (viewModel, _) = makeTestableViewModel() diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 332489a6..c598d201 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -459,6 +459,12 @@ private final class PerfNostrContext: ChatNostrContext { func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {} func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {} func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {} + + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { nil } + func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { [] } + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {} + func postLocalNotification(title: String, body: String, identifier: String) {} + func notifyGeohashActivity(geohash: String, bodyPreview: String) {} } // MARK: - Mock ChatDeliveryContext diff --git a/bitchatTests/SchnorrConcurrencyRepro.swift b/bitchatTests/SchnorrConcurrencyRepro.swift new file mode 100644 index 00000000..47ef9375 --- /dev/null +++ b/bitchatTests/SchnorrConcurrencyRepro.swift @@ -0,0 +1,82 @@ +import Testing +import Foundation +@testable import bitchat + +struct SchnorrConcurrencyRepro { + @Test func concurrentVerificationOfValidEventAlwaysSucceeds() async throws { + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [["g", "sn"]], + content: "concurrency probe" + ) + let signed = try event.sign(with: identity.schnorrSigningKey()) + #expect(signed.isValidSignature()) + + let failures = await withTaskGroup(of: Int.self) { group in + for _ in 0..<8 { + group.addTask { + var localFailures = 0 + for _ in 0..<250 { + if !signed.isValidSignature() { localFailures += 1 } + } + return localFailures + } + } + var total = 0 + for await f in group { total += f } + return total + } + #expect(failures == 0, "Schnorr verification returned false \(failures)/2000 times under concurrency") + } + + @Test func verificationSurvivesConcurrentSigningAndKeyGeneration() async throws { + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [["g", "sn"]], + content: "concurrency probe" + ) + let signed = try event.sign(with: identity.schnorrSigningKey()) + #expect(signed.isValidSignature()) + + // Signing and key generation may mutate shared library state + // (secp256k1 context randomization); verification must stay correct + // while they run on other tasks. + let failures = await withTaskGroup(of: Int.self) { group in + for worker in 0..<8 { + group.addTask { + if worker < 4 { + var localFailures = 0 + for _ in 0..<250 { + if !signed.isValidSignature() { localFailures += 1 } + } + return localFailures + } else { + for i in 0..<100 { + if let id = try? NostrIdentity.generate() { + let e = NostrEvent( + pubkey: id.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [], + content: "signer \(worker)-\(i)" + ) + _ = try? e.sign(with: id.schnorrSigningKey()) + } + } + return 0 + } + } + } + var total = 0 + for await f in group { total += f } + return total + } + #expect(failures == 0, "Schnorr verification returned false \(failures)/1000 times while signing ran concurrently") + } +} From 09087b74cc3cd3b28621df76ed0a061b6c97d53d Mon Sep 17 00:00:00 2001 From: jack Date: Wed, 10 Jun 2026 23:07:04 +0200 Subject: [PATCH 8/9] Add coverage reporting to CI swift test runs with --enable-code-coverage and each matrix job prints an llvm-cov per-file + total summary (informational only - no thresholds, so coverage can never be the reason a build goes red). Local baseline at introduction: 69.7% lines. Co-Authored-By: Claude Fable 5 --- .github/workflows/swift-tests.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/swift-tests.yml b/.github/workflows/swift-tests.yml index 846b16f7..c9265e89 100644 --- a/.github/workflows/swift-tests.yml +++ b/.github/workflows/swift-tests.yml @@ -39,7 +39,23 @@ jobs: ${{ runner.os }}-${{ matrix.name }}- - name: Run Tests - run: swift test --parallel --quiet --package-path ${{ matrix.path }} + run: swift test --parallel --quiet --enable-code-coverage --package-path ${{ matrix.path }} + + # Informational only: surfaces per-file and total line coverage in the + # job log so coverage trends are visible on every PR. No thresholds โ€” + # this must never be the reason a build goes red. + - name: Coverage summary + run: | + BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }}) + PROF="$BIN_PATH/codecov/default.profdata" + XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1) + BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)" + if [ -f "$PROF" ] && [ -f "$BINARY" ]; then + xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \ + -ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true + else + echo "No coverage data found; skipping summary." + fi # SPM tests above only compile the macOS slice; this job covers the # iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.). From 6c0dbbbd0d400b58c28a85a688e4ec4d592ce24f Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 11 Jun 2026 09:50:47 +0200 Subject: [PATCH 9/9] Make lifecycle delayed read-pass deterministic in tests The coordinator scheduled its delayed owner-level read pass via DispatchQueue.main.asyncAfter, which a busy CI runner's main queue can delay past any reasonable polling deadline. Scheduling is now an injected context member (scheduleOnMainAfter); the ChatViewModel witness keeps the exact asyncAfter behavior while the test mock runs the work synchronously, eliminating the wall-clock poll entirely. Co-Authored-By: Claude Fable 5 --- .../ViewModels/ChatLifecycleCoordinator.swift | 17 +++++++++++++---- .../ChatLifecycleCoordinatorContextTests.swift | 14 ++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index ddbea7ab..43d29459 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -30,6 +30,9 @@ protocol ChatLifecycleContext: AnyObject { func markPrivateMessagesAsRead(from peerID: PeerID) /// Marks the chat read in the private chat manager (sends pending mesh READ acks). func markChatAsRead(from peerID: PeerID) + /// Schedules main-actor work after a UI-timing delay. Injected so tests + /// can run the work synchronously instead of polling wall-clock queues. + func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) func synchronizePrivateConversationStore() func addSystemMessage(_ content: String) @@ -83,6 +86,14 @@ extension ChatViewModel: ChatLifecycleContext { privateChatManager.markAsRead(from: peerID) } + func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + Task { @MainActor in + work() + } + } + } + func stopMeshServices() { meshService.stopServices() } @@ -119,10 +130,8 @@ final class ChatLifecycleCoordinator { markPrivateMessagesAsRead(from: peerID) let context = self.context - DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak context] in - Task { @MainActor in - context?.markPrivateMessagesAsRead(from: peerID) - } + context.scheduleOnMainAfter(TransportConfig.uiAnimationMediumSeconds) { [weak context] in + context?.markPrivateMessagesAsRead(from: peerID) } } diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index ca3afee8..45303bbc 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -54,6 +54,13 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { managerReadMarks.append(peerID) } + // Scheduled work runs synchronously so tests never poll wall-clock queues. + private(set) var scheduledDelays: [TimeInterval] = [] + func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) { + scheduledDelays.append(delay) + work() + } + func synchronizePrivateConversationStore() { privateStoreSyncCount += 1 } func addSystemMessage(_ content: String) { systemMessages.append(content) } @@ -299,10 +306,9 @@ struct ChatLifecycleCoordinatorContextTests { #expect(context.refreshBluetoothStateCount == 2) #expect(context.managerReadMarks == [peerID]) - let deadline = Date().addingTimeInterval(2) - while context.ownerLevelReadPasses.isEmpty && Date() < deadline { - try? await Task.sleep(nanoseconds: 20_000_000) - } + // The mock executes scheduled work synchronously, so the delayed + // owner-level pass has already run - no wall-clock polling. + #expect(context.scheduledDelays == [TransportConfig.uiAnimationMediumSeconds]) #expect(context.ownerLevelReadPasses == [peerID]) } @Test @MainActor