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.). 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/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/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/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index a74b6882..43d29459 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -2,36 +2,143 @@ 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 -final class ChatLifecycleCoordinator { - private unowned let viewModel: ChatViewModel +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) + /// 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) - init(viewModel: ChatViewModel) { - self.viewModel = viewModel + // 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: 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() + /// 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:)`, `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. + + func markChatAsRead(from peerID: PeerID) { + privateChatManager.markAsRead(from: peerID) } - func handleDidBecomeActive() { - if let bleService = viewModel.meshService as? BLEService { - let currentState = bleService.getCurrentBluetoothState() - viewModel.updateBluetoothState(currentState) - } - - guard let peerID = viewModel.selectedPrivateChatPeer else { return } - - markPrivateMessagesAsRead(from: peerID) - - let viewModel = self.viewModel - DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak viewModel] in + func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { Task { @MainActor in - viewModel?.markPrivateMessagesAsRead(from: peerID) + work() } } } - func handleScreenshotCaptured() { - let screenshotMessage = "* \(viewModel.nickname) took a screenshot *" + func stopMeshServices() { + meshService.stopServices() + } - if let peerID = viewModel.selectedPrivateChatPeer { + 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 context: any ChatLifecycleContext + + init(context: any ChatLifecycleContext) { + self.context = context + } + + func handleDidBecomeActive() { + context.refreshBluetoothState() + + guard let peerID = context.selectedPrivateChatPeer else { return } + + markPrivateMessagesAsRead(from: peerID) + + let context = self.context + context.scheduleOnMainAfter(TransportConfig.uiAnimationMediumSeconds) { [weak context] in + context?.markPrivateMessagesAsRead(from: peerID) + } + } + + func handleScreenshotCaptured() { + let screenshotMessage = "* \(context.nickname) took a screenshot *" + + if let peerID = context.selectedPrivateChatPeer { sendPrivateScreenshotNotificationIfPossible( screenshotMessage, to: peerID @@ -40,9 +147,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 +163,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 } @@ -101,16 +206,16 @@ 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 = 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) + let favoriteStatus = context.favoriteRelationship(forNoiseKey: 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 +226,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 +280,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 +308,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 +341,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/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index 54ef82cf..0b52baa6 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -1,930 +1,66 @@ 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 set } - var geoDmSubscriptionID: String? { get set } - /// Geohash sampling subscriptions: subscription ID -> geohash. - var geoSamplingSubs: [String: String] { get set } - /// 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 set } - 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) + var nostrKeyMapping: [PeerID: String] { get } 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) 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 { - // `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`, + // `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 +/// 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] = [] + 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.geoDmSubscriptionID = nil - } - - if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { - let dmSub = "geo-dm-\(channel.geohash)" - context.geoDmSubscriptionID = 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 } - guard event.isValidSignature() else { return } - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue - || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), - !context.hasProcessedNostrEvent(event.id) - 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.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey - context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = 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 } - guard giftWrap.isValidSignature() else { return } - guard !context.hasProcessedNostrEvent(giftWrap.id) 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.nostrKeyMapping[convKey] = senderPubkey - - 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.geoSubscriptionID = nil - } - if let dmSub = context.geoDmSubscriptionID { - NostrRelayManager.shared.unsubscribe(id: dmSub) - context.geoDmSubscriptionID = 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.geoSubscriptionID = 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 } - guard event.isValidSignature() else { return } - guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue - || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) - else { - return - } - - 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) - - 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.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey - context.nostrKeyMapping[PeerID(nostr: event.pubkey)] = 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.geoDmSubscriptionID = 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 } - guard giftWrap.isValidSignature() else { return } - if context.hasProcessedNostrEvent(giftWrap.id) { - 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.nostrKeyMapping[convKey] = senderPubkey - - 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.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = 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.geoSamplingSubs.removeValue(forKey: subID) - } - - for gh in toAdd { - subscribe(gh) - } - } - - @MainActor - func subscribe(_ gh: String) { - guard let context else { return } - let subID = "geo-sample-\(gh)" - context.geoSamplingSubs[subID] = 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 } - guard giftWrap.isValidSignature() else { return } - if context.hasProcessedNostrEvent(giftWrap.id) { return } - context.recordProcessedNostrEvent(giftWrap.id) - - 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 } - 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.nostrKeyMapping[targetPeerID] = senderPubkey - - 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 @@ -964,16 +100,17 @@ final class ChatNostrCoordinator { @MainActor func handleFavoriteNotification(content: String, from nostrPubkey: String) { - guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return } + 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 ) } @@ -998,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)" @@ -1015,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 @@ -1041,7 +178,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) } @@ -1060,20 +197,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/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/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 6a3047cb..0d50ea90 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -3,24 +3,236 @@ 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: 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] + /// 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) + } + + // `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 { + 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 +242,7 @@ final class ChatPeerIdentityCoordinator { @MainActor func isPeerBlocked(_ peerID: PeerID) -> Bool { - viewModel.unifiedPeerService.isBlocked(peerID) + context.unifiedIsBlocked(peerID) } @MainActor @@ -38,24 +250,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,46 +278,46 @@ final class ChatPeerIdentityCoordinator { return } - viewModel.unifiedPeerService.toggleFavorite(peerID) - viewModel.objectWillChange.send() + context.unifiedToggleFavorite(peerID) + context.notifyUIChanged() } @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 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 +330,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 +345,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 +358,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 +386,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 +404,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 +431,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 +450,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 +472,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 +491,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.nostrKeyMapping[conversationKey] = person.id + context.registerNostrKeyMapping(person.id, for: conversationKey) return conversationKey } @@ -310,9 +511,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.nostrKeyMapping[conversationKey] = pubkey + context.registerNostrKeyMapping(pubkey, for: conversationKey) return conversationKey } @@ -320,20 +521,20 @@ 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), + 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 } @@ -344,7 +545,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 +553,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,48 +568,48 @@ 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) { + if let favorite = context.favoriteRelationship(forNoiseKey: peerPublicKey) { return favorite.peerNickname } return "Unknown" @@ -417,7 +618,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,36 +626,36 @@ 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 currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey) + 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 { 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) } - 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/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index 80a24f2b..71145fe0 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -2,16 +2,72 @@ 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() + + // MARK: Notifications + /// Posts the "bitchatters nearby" local notification. + func notifyNetworkAvailable(peerCount: Int) +} + +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 + } + + func notifyNetworkAvailable(peerCount: Int) { + NotificationService.shared.sendNetworkAvailableNotification(peerCount: peerCount) + } +} + 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 +85,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() } } @@ -67,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 @@ -79,34 +135,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 +177,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 +217,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/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index 66dc766b..2ae4532c 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() @@ -59,11 +69,21 @@ 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 { - // `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 } @@ -149,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 @@ -187,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 @@ -381,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() @@ -425,15 +458,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,14 +610,10 @@ 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( - from: message.sender, - message: message.content, - peerID: peerID - ) + context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID) } context.notifyUIChanged() @@ -654,10 +681,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 @@ -682,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) } } @@ -706,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 { @@ -802,17 +825,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..41ae4ffd 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) @@ -83,12 +88,16 @@ 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 { // `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 @@ -179,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 @@ -247,9 +260,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( @@ -545,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) } } @@ -616,7 +627,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { } func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) { - context.isBatchingPublic = isBatching + context.setPublicBatching(isBatching) } } 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..356fbd7f 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -3,6 +3,123 @@ 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) + + // MARK: Notifications (shared with `ChatNostrContext`) + /// Posts a generic local user notification. + func postLocalNotification(title: String, body: String, identifier: String) +} + +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) + } + + func postLocalNotification(title: String, body: String, identifier: String) { + NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: identifier) + } +} + @MainActor final class ChatVerificationCoordinator { struct PendingVerification { @@ -13,44 +130,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 +175,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 +251,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 +265,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 +273,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,17 +311,17 @@ 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) - NotificationService.shared.sendLocalNotification( + let peerName = context.unifiedPeer(for: peerID)?.nickname + ?? context.resolveNickname(for: peerID) + context.postLocalNotification( title: "Verified", body: "You verified \(peerName)", identifier: "verify-success-\(peerID)-\(UUID().uuidString)" @@ -225,7 +338,7 @@ final class ChatVerificationCoordinator { ) } - viewModel.updateEncryptionStatus(for: peerID) + context.updateEncryptionStatus(for: peerID) } } @@ -242,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/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 090a8afd..4318ce1e 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -146,19 +146,19 @@ 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 lifecycleCoordinator = ChatLifecycleCoordinator(viewModel: self) - private lazy var transportEventCoordinator = ChatTransportEventCoordinator(viewModel: self) - private lazy var peerListCoordinator = ChatPeerListCoordinator(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(context: 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 composerCoordinator = ChatComposerCoordinator(context: 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 @@ -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,115 @@ 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 + } + + /// 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 + 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/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/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/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/bitchat/ViewModels/GeoPresenceTracker.swift b/bitchat/ViewModels/GeoPresenceTracker.swift new file mode 100644 index 00000000..100041a9 --- /dev/null +++ b/bitchat/ViewModels/GeoPresenceTracker.swift @@ -0,0 +1,199 @@ +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) + + /// Posts the sampled-geohash-activity local notification. + func notifyGeohashActivity(geohash: String, bodyPreview: 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) + } + + func notifyGeohashActivity(geohash: String, bodyPreview: String) { + NotificationService.shared.sendGeohashActivityNotification(geohash: geohash, bodyPreview: bodyPreview) + } +} + +/// 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..95d9b234 --- /dev/null +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -0,0 +1,500 @@ +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: 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. + 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 allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { + Array(FavoritesPersistenceService.shared.favorites.values) + } + + 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? { + guard let context else { return nil } + let favorites = context.allFavoriteRelationships() + 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/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/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift new file mode 100644 index 00000000..45303bbc --- /dev/null +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -0,0 +1,346 @@ +// +// ChatLifecycleCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatLifecycleCoordinator` against a mock `ChatLifecycleContext` +// โ€” proving the coordinator works without a `ChatViewModel`, following the +// `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// 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 +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) + } + + // 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) } + + // 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) } + + // 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 + + 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 + +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, + 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]) + + // 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 + 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/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/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 73ad3b96..d9d68c4c 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 @@ -28,6 +30,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 +83,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)] = [] @@ -202,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 @@ -215,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 @@ -239,7 +298,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. @@ -257,7 +316,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) @@ -277,7 +336,7 @@ struct ChatNostrCoordinatorContextTests { teleported: true ) - coordinator.handleNostrEvent(event) + coordinator.inbound.handleNostrEvent(event) await drainMainQueue() // Teleport detection fires even though the empty message is dropped. @@ -299,7 +358,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. @@ -326,7 +385,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]) @@ -343,11 +402,35 @@ 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) } + @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() @@ -356,7 +439,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) @@ -440,3 +523,179 @@ 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) + } + @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/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/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift new file mode 100644 index 00000000..e162b77d --- /dev/null +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -0,0 +1,433 @@ +// +// ChatPeerIdentityCoordinatorContextTests.swift +// bitchatTests +// +// Exercises `ChatPeerIdentityCoordinator` against a mock +// `ChatPeerIdentityContext` โ€” proving the coordinator works without a +// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / +// `ChatPrivateConversationCoordinatorContextTests` exemplars. +// +// 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 +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)) + } + + // 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, + 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) + } + @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 new file mode 100644 index 00000000..67e433f5 --- /dev/null +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -0,0 +1,207 @@ +// +// 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 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. +// + +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 } + + // Notifications + private(set) var networkAvailableNotifications: [Int] = [] + + func notifyNetworkAvailable(peerCount: Int) { + networkAvailableNotifications.append(peerCount) + } +} + +// 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) + } + + @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 dc55fdca..7a6fcf67 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 } @@ -117,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] = [] @@ -176,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 @@ -387,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 68322fd3..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 @@ -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 } @@ -239,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), @@ -492,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/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..4ef7f9f7 --- /dev/null +++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift @@ -0,0 +1,321 @@ +// +// 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; 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 +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)) + } + + // 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 + +/// 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) + } + + @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 +/// let that queue drain before asserting. +@MainActor +private func waitForMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } +} 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 4af153e0..b275358c 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", @@ -398,26 +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) - - viewModel.handleNostrMessage(invalidGiftWrap) - #expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id)) - - viewModel.handleNostrMessage(giftWrap) - #expect(viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id)) - } - @Test @MainActor func switchLocationChannel_clearsNostrDedupCache() async { let (viewModel, _) = makeTestableViewModel() @@ -777,7 +757,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) @@ -817,6 +797,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/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) + } +} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift new file mode 100644 index 00000000..c598d201 --- /dev/null +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -0,0 +1,529 @@ +// +// 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) + + /// `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 { + 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.inbound.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.inbound.handleNostrEvent(event) + } + XCTAssertEqual(context.processedEventCount, events.count) + var samples: [TimeInterval] = [] + + measure { + let start = Date() + for event in events { + coordinator.inbound.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 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 +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 } + + 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) {} + 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 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) {} + 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) {} + + 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 + +@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) {} + + 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 { + 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)") } +} 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") + } +} 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") + } +}