diff --git a/bitchat/ViewModels/ChatLifecycleCoordinator.swift b/bitchat/ViewModels/ChatLifecycleCoordinator.swift index 4e4ad3f3..ddbea7ab 100644 --- a/bitchat/ViewModels/ChatLifecycleCoordinator.swift +++ b/bitchat/ViewModels/ChatLifecycleCoordinator.swift @@ -53,6 +53,10 @@ protocol ChatLifecycleContext: AnyObject { func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity func recordGeoParticipant(pubkeyHex: String) + // MARK: Favorites (shared with `ChatPrivateConversationContext`) + /// The persisted favorite relationship for the peer's Noise static key, if any. + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? + // MARK: Identity persistence /// Forces the identity manager to persist its state now. func forceSaveIdentity() @@ -69,7 +73,8 @@ extension ChatViewModel: ChatLifecycleContext { // `synchronizePrivateConversationStore()`, `addSystemMessage(_:)`, // `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`, // the routing/ack members, `isTeleported`, - // `deriveNostrIdentity(forGeohash:)`, and `recordGeoParticipant(pubkeyHex:)` + // `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`, + // and `favoriteRelationship(forNoiseKey:)` // are shared requirements with the other contexts or satisfied by // existing `ChatViewModel` members. The members below flatten nested // service accesses into intent-named calls. @@ -192,12 +197,12 @@ final class ChatLifecycleCoordinator { var peerNostrPubkey: String? if let noiseKey = Data(hexString: peerID.id), - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) { + let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) { noiseKeyHex = peerID peerNostrPubkey = favoriteStatus.peerNostrPublicKey } else if let peer = context.unifiedPeer(for: peerID) { noiseKeyHex = PeerID(hexData: peer.noisePublicKey) - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey) + let favoriteStatus = context.favoriteRelationship(forNoiseKey: peer.noisePublicKey) peerNostrPubkey = favoriteStatus?.peerNostrPublicKey if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) { diff --git a/bitchat/ViewModels/ChatNostrCoordinator.swift b/bitchat/ViewModels/ChatNostrCoordinator.swift index e56ce1db..0b52baa6 100644 --- a/bitchat/ViewModels/ChatNostrCoordinator.swift +++ b/bitchat/ViewModels/ChatNostrCoordinator.swift @@ -20,12 +20,23 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) + + // MARK: Favorites & notifications (shared with the other contexts) + /// The persisted favorite relationship for the peer's Noise static key, if any. + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? + /// Adds (or updates) a favorite in the favorites store. + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) + /// Posts a generic local user notification. + func postLocalNotification(title: String, body: String, identifier: String) } extension ChatViewModel: ChatNostrContext { // All requirements — including the component-context witnesses declared // in `GeohashSubscriptionManager.swift`, `NostrInboundPipeline.swift`, - // and `GeoPresenceTracker.swift` — already exist on `ChatViewModel`. + // `GeoPresenceTracker.swift`, and the favorites/notification witnesses in + // `ChatPrivateConversationCoordinator.swift`, + // `ChatPeerIdentityCoordinator.swift`, and + // `ChatVerificationCoordinator.swift` — already exist on `ChatViewModel`. } /// Thin facade over the Nostr stack: owns and wires the three components and @@ -89,16 +100,17 @@ final class ChatNostrCoordinator { @MainActor func handleFavoriteNotification(content: String, from nostrPubkey: String) { + guard let context else { return } guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return } let isFavorite = content.contains("FAVORITE:TRUE") let senderNickname = content.components(separatedBy: "|").last ?? "Unknown" if isFavorite { - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: senderNoiseKey, - peerNostrPublicKey: nostrPubkey, - peerNickname: senderNickname + context.addFavorite( + noiseKey: senderNoiseKey, + nostrPublicKey: nostrPubkey, + nickname: senderNickname ) } @@ -123,14 +135,14 @@ final class ChatNostrCoordinator { "💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...", category: .session ) - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: senderNoiseKey, - peerNostrPublicKey: extractedNostrPubkey, - peerNickname: senderNickname + context.addFavorite( + noiseKey: senderNoiseKey, + nostrPublicKey: extractedNostrPubkey, + nickname: senderNickname ) } - NotificationService.shared.sendLocalNotification( + context.postLocalNotification( title: isFavorite ? "New Favorite" : "Favorite Removed", body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you", identifier: "fav-\(UUID().uuidString)" @@ -140,7 +152,7 @@ final class ChatNostrCoordinator { @MainActor func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { guard let context else { return } - guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey), + guard let relationship = context.favoriteRelationship(forNoiseKey: noisePublicKey), relationship.peerNostrPublicKey != nil else { SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session) return diff --git a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift index 320f7caf..0d50ea90 100644 --- a/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerIdentityCoordinator.swift @@ -76,6 +76,16 @@ protocol ChatPeerIdentityContext: AnyObject { func invalidateStoredEncryptionCache(for peerID: PeerID?) func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? + // MARK: Favorites + /// The persisted favorite relationship for the peer's Noise static key, if any. + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? + /// The persisted favorite relationship for a short (ephemeral) peer ID, if any. + func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? + /// Adds (or updates) a favorite in the favorites store. + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) + /// Removes a favorite from the favorites store. + func removeFavorite(noiseKey: Data) + // MARK: Geohash & Nostr var geoNicknames: [String: String] { get } func visibleGeohashPeople() -> [GeoPerson] @@ -183,6 +193,26 @@ extension ChatViewModel: ChatPeerIdentityContext { func bridgedNostrPublicKey(for noiseKey: Data) -> String? { idBridge.getNostrPublicKey(for: noiseKey) } + + // `favoriteRelationship(forNoiseKey:)` is shared with + // `ChatPrivateConversationContext`; its witness lives in + // `ChatPrivateConversationCoordinator.swift`. + + func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? { + FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID) + } + + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) { + FavoritesPersistenceService.shared.addFavorite( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrPublicKey, + peerNickname: nickname + ) + } + + func removeFavorite(noiseKey: Data) { + FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) + } } final class ChatPeerIdentityCoordinator { @@ -255,7 +285,7 @@ final class ChatPeerIdentityCoordinator { @MainActor func isFavorite(peerID: PeerID) -> Bool { if let noisePublicKey = peerID.noiseKey { - return FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)?.isFavorite ?? false + return context.favoriteRelationship(forNoiseKey: noisePublicKey)?.isFavorite ?? false } return context.unifiedPeer(for: peerID)?.isFavorite ?? false @@ -499,12 +529,12 @@ final class ChatPeerIdentityCoordinator { if let name = context.peerNickname(for: peerID) { return name } - if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), + if let favorite = context.favoriteRelationship(forPeerID: peerID), !favorite.peerNickname.isEmpty { return favorite.peerNickname } if let noiseKey = Data(hexString: peerID.id), - let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), + let favorite = context.favoriteRelationship(forNoiseKey: noiseKey), !favorite.peerNickname.isEmpty { return favorite.peerNickname } @@ -579,7 +609,7 @@ private extension ChatPeerIdentityCoordinator { if let nickname = context.peerNickname(for: peerID) { return nickname } - if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) { + if let favorite = context.favoriteRelationship(forNoiseKey: peerPublicKey) { return favorite.peerNickname } return "Unknown" @@ -602,7 +632,7 @@ private extension ChatPeerIdentityCoordinator { return } - let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey) + let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey) let fallbackNickname = context.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender let plan = ChatFavoriteTogglePolicy.plan( currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init), @@ -612,14 +642,14 @@ private extension ChatPeerIdentityCoordinator { switch plan.persistenceAction { case .add(let nickname, let nostrKey): - FavoritesPersistenceService.shared.addFavorite( - peerNoisePublicKey: noisePublicKey, - peerNostrPublicKey: nostrKey, - peerNickname: nickname + context.addFavorite( + noiseKey: noisePublicKey, + nostrPublicKey: nostrKey, + nickname: nickname ) case .remove: - FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey) + context.removeFavorite(noiseKey: noisePublicKey) } context.notifyUIChanged() diff --git a/bitchat/ViewModels/ChatPeerListCoordinator.swift b/bitchat/ViewModels/ChatPeerListCoordinator.swift index f2fff0af..71145fe0 100644 --- a/bitchat/ViewModels/ChatPeerListCoordinator.swift +++ b/bitchat/ViewModels/ChatPeerListCoordinator.swift @@ -28,6 +28,10 @@ protocol ChatPeerListContext: AnyObject { func activeMeshPeerCount() -> Int func registerEphemeralSession(peerID: PeerID) func updateEncryptionStatusForPeers() + + // MARK: Notifications + /// Posts the "bitchatters nearby" local notification. + func notifyNetworkAvailable(peerCount: Int) } extension ChatViewModel: ChatPeerListContext { @@ -48,6 +52,10 @@ extension ChatViewModel: ChatPeerListContext { } .count } + + func notifyNetworkAvailable(peerCount: Int) { + NotificationService.shared.sendNetworkAvailableNotification(peerCount: peerCount) + } } final class ChatPeerListCoordinator: @unchecked Sendable { @@ -115,7 +123,7 @@ private extension ChatPeerListCoordinator { if Date().timeIntervalSince(lastNetworkNotificationTime) >= cooldown { recentlySeenPeers.formUnion(newPeers) lastNetworkNotificationTime = Date() - NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) + context.notifyNetworkAvailable(peerCount: meshPeers.count) SecureLogger.info( "👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", category: .session diff --git a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift index ea1d28e0..2ae4532c 100644 --- a/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPrivateConversationCoordinator.swift @@ -69,6 +69,14 @@ protocol ChatPrivateConversationContext: AnyObject { func addSystemMessage(_ content: String) func addMeshOnlySystemMessage(_ content: String) func sanitizeChat(for peerID: PeerID) + + // MARK: Favorites & notifications + /// The persisted favorite relationship for the peer's Noise static key, if any. + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? + /// Persists that the peer favorited/unfavorited us (favorites store write). + func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) + /// Posts the incoming-private-message local notification. + func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) } extension ChatViewModel: ChatPrivateConversationContext { @@ -161,6 +169,23 @@ extension ChatViewModel: ChatPrivateConversationContext { privateChatManager.sanitizeChat(for: peerID) } + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { + FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) + } + + func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { + FavoritesPersistenceService.shared.updatePeerFavoritedUs( + peerNoisePublicKey: noiseKey, + favorited: favorited, + peerNickname: nickname, + peerNostrPublicKey: nostrPublicKey + ) + } + + func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) { + NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID) + } + private func makeGeohashNostrTransport() -> NostrTransport { let transport = NostrTransport(keychain: keychain, idBridge: idBridge) transport.senderPeerID = meshService.myPeerID @@ -199,7 +224,7 @@ final class ChatPrivateConversationCoordinator { guard let noiseKey = Data(hexString: peerID.id) else { return } let isConnected = context.isPeerConnected(peerID) let isReachable = context.isPeerReachable(peerID) - let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) + let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) let isMutualFavorite = favoriteStatus?.isMutual ?? false let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil @@ -393,11 +418,7 @@ final class ChatPrivateConversationCoordinator { } if !isViewing && shouldMarkUnread { - NotificationService.shared.sendPrivateMessageNotification( - from: senderName, - message: pm.content, - peerID: convKey - ) + context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey) } context.notifyUIChanged() @@ -592,11 +613,7 @@ final class ChatPrivateConversationCoordinator { context.markReadReceiptSent(message.id) } else { context.unreadPrivateMessages.insert(peerID) - NotificationService.shared.sendPrivateMessageNotification( - from: message.sender, - message: message.content, - peerID: peerID - ) + context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID) } context.notifyUIChanged() @@ -692,11 +709,7 @@ final class ChatPrivateConversationCoordinator { context.unreadPrivateMessages.insert(ephemeralPeerID) } if isRecentMessage { - NotificationService.shared.sendPrivateMessageNotification( - from: senderNickname, - message: messageContent, - peerID: targetPeerID - ) + context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID) } } @@ -716,12 +729,12 @@ final class ChatPrivateConversationCoordinator { return } - let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false - FavoritesPersistenceService.shared.updatePeerFavoritedUs( - peerNoisePublicKey: finalNoiseKey, + let prior = context.favoriteRelationship(forNoiseKey: finalNoiseKey)?.theyFavoritedUs ?? false + context.updatePeerFavoritedUs( + noiseKey: finalNoiseKey, favorited: isFavorite, - peerNickname: senderNickname, - peerNostrPublicKey: nostrPubkey + nickname: senderNickname, + nostrPublicKey: nostrPubkey ) if isFavorite && nostrPubkey != nil { diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index c7627ca3..41ae4ffd 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -88,6 +88,10 @@ protocol ChatPublicConversationContext: AnyObject { func recordContentKey(_ key: String, timestamp: Date) /// Pre-renders the message so the formatting cache is warm before display. func prewarmMessageFormatting(_ message: BitchatMessage) + + // MARK: Notifications + /// Posts the you-were-mentioned local notification. + func notifyMention(from sender: String, message: String) } extension ChatViewModel: ChatPublicConversationContext { @@ -184,6 +188,10 @@ extension ChatViewModel: ChatPublicConversationContext { func prewarmMessageFormatting(_ message: BitchatMessage) { _ = formatMessageAsText(message, colorScheme: currentColorScheme) } + + func notifyMention(from sender: String, message: String) { + NotificationService.shared.sendMentionNotification(from: sender, message: message) + } } @MainActor @@ -548,7 +556,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { if isMentioned && message.sender != context.nickname { SecureLogger.info("🔔 Mention from \(message.sender)", category: .session) - NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) + context.notifyMention(from: message.sender, message: message.content) } } diff --git a/bitchat/ViewModels/ChatVerificationCoordinator.swift b/bitchat/ViewModels/ChatVerificationCoordinator.swift index b61aec2f..356fbd7f 100644 --- a/bitchat/ViewModels/ChatVerificationCoordinator.swift +++ b/bitchat/ViewModels/ChatVerificationCoordinator.swift @@ -56,6 +56,10 @@ protocol ChatVerificationContext: AnyObject { func triggerHandshake(with peerID: PeerID) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) + + // MARK: Notifications (shared with `ChatNostrContext`) + /// Posts a generic local user notification. + func postLocalNotification(title: String, body: String, identifier: String) } extension ChatViewModel: ChatVerificationContext { @@ -110,6 +114,10 @@ extension ChatViewModel: ChatVerificationContext { func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) } + + func postLocalNotification(title: String, body: String, identifier: String) { + NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: identifier) + } } @MainActor @@ -313,7 +321,7 @@ final class ChatVerificationCoordinator { let peerName = context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID) - NotificationService.shared.sendLocalNotification( + context.postLocalNotification( title: "Verified", body: "You verified \(peerName)", identifier: "verify-success-\(peerID)-\(UUID().uuidString)" @@ -347,7 +355,7 @@ private extension ChatVerificationCoordinator { guard now.timeIntervalSince(lastToast) > 60 else { return } lastMutualToastAt[fingerprint] = now - NotificationService.shared.sendLocalNotification( + context.postLocalNotification( title: title, body: "You and \(bodyName) verified each other", identifier: "\(notificationPrefix)-\(peerID)-\(UUID().uuidString)" diff --git a/bitchat/ViewModels/GeoPresenceTracker.swift b/bitchat/ViewModels/GeoPresenceTracker.swift index 587982c3..100041a9 100644 --- a/bitchat/ViewModels/GeoPresenceTracker.swift +++ b/bitchat/ViewModels/GeoPresenceTracker.swift @@ -25,6 +25,9 @@ protocol GeoPresenceContext: AnyObject { func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool func synchronizePublicConversationStore(forGeohash geohash: String) + + /// Posts the sampled-geohash-activity local notification. + func notifyGeohashActivity(geohash: String, bodyPreview: String) } extension ChatViewModel: GeoPresenceContext { @@ -52,6 +55,10 @@ extension ChatViewModel: GeoPresenceContext { func markGeoTeleported(_ pubkeyHexLowercased: String) { locationPresenceStore.markTeleported(pubkeyHexLowercased) } + + func notifyGeohashActivity(geohash: String, bodyPreview: String) { + NotificationService.shared.sendGeohashActivityNotification(geohash: geohash, bodyPreview: bodyPreview) + } } /// Geohash presence bookkeeping that is independent of relay subscriptions: @@ -160,7 +167,7 @@ final class GeoPresenceTracker { ) if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) { context.synchronizePublicConversationStore(forGeohash: gh) - NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview) + context.notifyGeohashActivity(geohash: gh, bodyPreview: preview) } } } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index a1d4b4d5..95d9b234 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -20,6 +20,11 @@ protocol NostrInboundPipelineContext: AnyObject { func isNostrBlocked(pubkeyHexLowercased: String) -> Bool func displayNameForNostrPubkey(_ pubkeyHex: String) -> String + // MARK: Favorites bridge + /// All favorite relationships, used to bridge a Nostr pubkey back to a + /// Noise key on the inbound DM path. + func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] + // MARK: Presence & key mapping func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) /// Records the Nostr pubkey behind a (possibly virtual) peer ID. @@ -53,6 +58,10 @@ extension ChatViewModel: NostrInboundPipelineContext { deduplicationService.hasProcessedNostrEvent(eventID) } + func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { + Array(FavoritesPersistenceService.shared.favorites.values) + } + func recordProcessedNostrEvent(_ eventID: String) { deduplicationService.recordNostrEvent(eventID) } @@ -437,7 +446,8 @@ final class NostrInboundPipeline { /// the favorites glue in `ChatNostrCoordinator` delegates to it. @MainActor func findNoiseKey(for nostrPubkey: String) -> Data? { - let favorites = FavoritesPersistenceService.shared.favorites.values + guard let context else { return nil } + let favorites = context.allFavoriteRelationships() var npubToMatch = nostrPubkey if !nostrPubkey.hasPrefix("npub") { diff --git a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift index 12693601..ca3afee8 100644 --- a/bitchatTests/ChatLifecycleCoordinatorContextTests.swift +++ b/bitchatTests/ChatLifecycleCoordinatorContextTests.swift @@ -7,12 +7,12 @@ // `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: the mesh/Nostr read-receipt branch of -// `markPrivateMessagesAsRead` consults `FavoritesPersistenceService.shared` -// and the geohash-screenshot branch publishes via `NostrRelayManager.shared` / -// `GeoRelayDirectory.shared`; those stay covered by the full view-model tests. -// The GeoDM read pass, message merging, screenshot notices, and lifecycle -// persistence flows are covered here. +// Scope note: the geohash-screenshot branch publishes via +// `NostrRelayManager.shared` / `GeoRelayDirectory.shared`; that stays covered +// by the full view-model tests. The GeoDM read pass, the favorites-backed +// mesh/Nostr read-receipt branch (favorites are injected through the +// context), message merging, screenshot notices, and lifecycle persistence +// flows are covered here. // import Testing @@ -101,6 +101,13 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity } func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) } + // Favorites + var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] + + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByNoiseKey[noiseKey] + } + // Identity persistence private(set) var forceSaveIdentityCount = 0 private(set) var verifyIdentityKeyExistsCount = 0 @@ -123,6 +130,24 @@ private final class MockChatLifecycleContext: ChatLifecycleContext { // MARK: - Helpers +private func makeFavoriteRelationship( + noiseKey: Data, + nostrPublicKey: String? = nil, + nickname: String = "alice", + isFavorite: Bool = false, + theyFavoritedUs: Bool = false +) -> FavoritesPersistenceService.FavoriteRelationship { + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrPublicKey, + peerNickname: nickname, + isFavorite: isFavorite, + theyFavoritedUs: theyFavoritedUs, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) +} + @MainActor private func makePrivateMessage( id: String, @@ -280,4 +305,36 @@ struct ChatLifecycleCoordinatorContextTests { } #expect(context.ownerLevelReadPasses == [peerID]) } + @Test @MainActor + func markPrivateMessagesAsRead_routesReceiptsOnlyForNostrReachableFavorites() { + let context = MockChatLifecycleContext() + let coordinator = ChatLifecycleCoordinator(context: context) + let noiseKey = Data(repeating: 0xAB, count: 32) + let peerID = PeerID(hexData: noiseKey) + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + nostrPublicKey: "npub1alice" + ) + context.privateChats[peerID] = [ + makePrivateMessage(id: "in-1", senderPeerID: peerID), + makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true), + ] + + coordinator.markPrivateMessagesAsRead(from: peerID) + + // Favorite with a Nostr key: READ receipts routed for non-relay + // inbound messages and recorded as sent. + #expect(context.managerReadMarks == [peerID]) + #expect(context.routedReadReceipts.map(\.messageID) == ["in-1"]) + #expect(context.routedReadReceipts.map(\.peerID) == [peerID]) + #expect(context.sentReadReceipts.contains("in-1")) + + // No favorite relationship (no Nostr key): the receipt pass is skipped. + let otherKey = Data(repeating: 0xCD, count: 32) + let otherPeer = PeerID(hexData: otherKey) + context.privateChats[otherPeer] = [makePrivateMessage(id: "in-2", senderPeerID: otherPeer)] + coordinator.markPrivateMessagesAsRead(from: otherPeer) + #expect(context.routedReadReceipts.map(\.messageID) == ["in-1"]) + } + } diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 795d6e8e..d9d68c4c 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -215,6 +215,32 @@ private final class MockChatNostrContext: ChatNostrContext { func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { geoReadReceipts.append((messageID, recipientHex)) } + + // Favorites & notifications + var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] + private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = [] + private(set) var postedLocalNotifications: [(title: String, body: String, identifier: String)] = [] + private(set) var geohashActivityNotifications: [(geohash: String, bodyPreview: String)] = [] + + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByNoiseKey[noiseKey] + } + + func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { + Array(favoriteRelationshipsByNoiseKey.values) + } + + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) { + addedFavorites.append((noiseKey, nostrPublicKey, nickname)) + } + + func postLocalNotification(title: String, body: String, identifier: String) { + postedLocalNotifications.append((title, body, identifier)) + } + + func notifyGeohashActivity(geohash: String, bodyPreview: String) { + geohashActivityNotifications.append((geohash, bodyPreview)) + } } // MARK: - Helpers @@ -228,14 +254,34 @@ private func drainMainQueue() async { } } +private func makeFavoriteRelationship( + noiseKey: Data, + nostrPublicKey: String? = nil, + nickname: String = "alice", + isFavorite: Bool = false, + theyFavoritedUs: Bool = false +) -> FavoritesPersistenceService.FavoriteRelationship { + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrPublicKey, + peerNickname: nickname, + isFavorite: isFavorite, + theyFavoritedUs: theyFavoritedUs, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) +} + // MARK: - Coordinator Tests Against Mock Context /// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no /// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence, /// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch -/// teardown, and embedded ack flows. Flows that hit live singletons -/// (`NostrRelayManager.shared` subscriptions, `TorManager`, -/// `FavoritesPersistenceService`) remain covered by the full view-model tests. +/// teardown, embedded ack flows, and — now that favorites and notifications +/// are injected through the context — the favorite-notification ingest and +/// the sampled-geohash notification cooldown. Flows that hit live singletons +/// (`NostrRelayManager.shared` subscriptions, `TorManager`) remain covered by +/// the full view-model tests. struct ChatNostrCoordinatorContextTests { @Test @MainActor @@ -361,6 +407,30 @@ struct ChatNostrCoordinatorContextTests { #expect(context.handledPrivateMessages.count == 1) } + @Test @MainActor + func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + + let recipient = try NostrIdentity.generate() + let sender = try NostrIdentity.generate() + let giftWrap = try NostrProtocol.createPrivateMessage( + content: "verify:noop", + recipientPubkey: recipient.publicKeyHex, + senderIdentity: sender + ) + var invalidGiftWrap = giftWrap + invalidGiftWrap.sig = String(repeating: "0", count: 128) + + // A forged-signature copy is rejected WITHOUT entering the dedup set... + await coordinator.inbound.processNostrMessage(invalidGiftWrap) + #expect(context.recordedNostrEventIDs.isEmpty) + + // ...so the genuine event with the same ID still processes and records. + await coordinator.inbound.processNostrMessage(giftWrap) + #expect(context.recordedNostrEventIDs == [giftWrap.id]) + } + @Test @MainActor func switchLocationChannel_toMesh_tearsDownGeohashState() async { let context = MockChatNostrContext() @@ -546,4 +616,86 @@ struct GeoPresenceTrackerTests { #expect(context.appendedGeohashMessages.count == 1) #expect(context.synchronizedGeohashes.isEmpty) } + @Test @MainActor + func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + let sender = try NostrIdentity.generate() + let noiseKey = Data(repeating: 0x42, count: 32) + // The favorites store bridges the sender's npub back to a Noise key. + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + nostrPublicKey: sender.npub + ) + + coordinator.handleFavoriteNotification(content: "FAVORITE:TRUE|alice", from: sender.publicKeyHex) + + #expect(context.addedFavorites.count == 1) + #expect(context.addedFavorites.first?.noiseKey == noiseKey) + #expect(context.addedFavorites.first?.nostrPublicKey == sender.publicKeyHex) + #expect(context.addedFavorites.first?.nickname == "alice") + #expect(context.postedLocalNotifications.count == 1) + #expect(context.postedLocalNotifications.first?.title == "New Favorite") + #expect(context.postedLocalNotifications.first?.body == "alice favorited you") + + // Unfavorite: no store write, but the removal notification still posts. + coordinator.handleFavoriteNotification(content: "FAVORITE:FALSE|alice", from: sender.publicKeyHex) + #expect(context.addedFavorites.count == 1) + #expect(context.postedLocalNotifications.last?.title == "Favorite Removed") + #expect(context.postedLocalNotifications.last?.body == "alice unfavorited you") + } + + @Test @MainActor + func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws { + let context = MockChatNostrContext() + let coordinator = ChatNostrCoordinator(context: context) + let sender = try NostrIdentity.generate() + context.geoNicknames[sender.publicKeyHex.lowercased()] = "alice" + + let first = try NostrProtocol.createEphemeralGeohashEvent( + content: "hello geohash", + geohash: "u4pruyd", + senderIdentity: sender, + nickname: "alice" + ) + coordinator.presence.cooldownPerGeohash("u4pruyd", content: "hello geohash", event: first) + await drainMainQueue() + + // Sampled message recorded, store synced, and notification posted. + #expect(context.appendedGeohashMessages.map(\.message.id) == [first.id]) + #expect(context.appendedGeohashMessages.first?.message.sender == "alice#" + String(first.pubkey.suffix(4))) + #expect(context.synchronizedGeohashes == ["u4pruyd"]) + #expect(context.geohashActivityNotifications.count == 1) + #expect(context.geohashActivityNotifications.first?.geohash == "u4pruyd") + #expect(context.geohashActivityNotifications.first?.bodyPreview == "hello geohash") + #expect(context.lastGeoNotificationAt["u4pruyd"] != nil) + + // A second sampled event inside the cooldown window is fully suppressed. + let second = try NostrProtocol.createEphemeralGeohashEvent( + content: "again", + geohash: "u4pruyd", + senderIdentity: sender, + nickname: "alice" + ) + coordinator.presence.cooldownPerGeohash("u4pruyd", content: "again", event: second) + await drainMainQueue() + #expect(context.geohashActivityNotifications.count == 1) + #expect(context.appendedGeohashMessages.count == 1) + + // Long previews are truncated to the snippet cap with an ellipsis. + let longContent = String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen + 20) + let third = try NostrProtocol.createEphemeralGeohashEvent( + content: longContent, + geohash: "9q8yyk", + senderIdentity: sender, + nickname: "alice" + ) + coordinator.presence.cooldownPerGeohash("9q8yyk", content: longContent, event: third) + await drainMainQueue() + #expect( + context.geohashActivityNotifications.last?.bodyPreview + == String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen) + "…" + ) + } + } diff --git a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift index bf0cbddb..e162b77d 100644 --- a/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift @@ -7,11 +7,10 @@ // `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: flows that hit the `FavoritesPersistenceService.shared` -// singleton (`isFavorite` / `toggleFavoriteForNoiseKey` / favorite -// notifications / `nicknameForPeer` fallbacks) remain covered by the full -// view-model tests; the session, migration, encryption-status, and nickname -// resolution flows are covered here. +// Scope note: favorites are injected through the context +// (`favoriteRelationship(forNoiseKey:)` / `addFavorite` / `removeFavorite`), +// so the favorite toggle and lookup flows are covered here alongside the +// session, migration, encryption-status, and nickname resolution flows. // import Testing @@ -171,10 +170,50 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext { func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) { nostrFavoriteNotifications.append((noisePublicKey, isFavorite)) } + + // Favorites + var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] + var favoriteRelationshipsByPeerID: [PeerID: FavoritesPersistenceService.FavoriteRelationship] = [:] + private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = [] + private(set) var removedFavorites: [Data] = [] + + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByNoiseKey[noiseKey] + } + + func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByPeerID[peerID] + } + + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) { + addedFavorites.append((noiseKey, nostrPublicKey, nickname)) + } + + func removeFavorite(noiseKey: Data) { + removedFavorites.append(noiseKey) + } } // MARK: - Helpers +private func makeFavoriteRelationship( + noiseKey: Data, + nostrPublicKey: String? = nil, + nickname: String = "alice", + isFavorite: Bool = false, + theyFavoritedUs: Bool = false +) -> FavoritesPersistenceService.FavoriteRelationship { + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrPublicKey, + peerNickname: nickname, + isFavorite: isFavorite, + theyFavoritedUs: theyFavoritedUs, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) +} + @MainActor private func makePrivateMessage( id: String, @@ -355,4 +394,40 @@ struct ChatPeerIdentityCoordinatorContextTests { context.peerIDsByNickname["carol"] = meshPeer #expect(coordinator.getPeerIDForNickname("carol") == meshPeer) } + @Test @MainActor + func toggleFavorite_forNoiseKeyPeer_usesInjectedFavoritesStore() async { + let context = MockChatPeerIdentityContext() + let coordinator = ChatPeerIdentityCoordinator(context: context) + let noiseKey = Data(repeating: 0xAB, count: 32) + let peerID = PeerID(hexData: noiseKey) + + // No prior relationship: adds a favorite, no Nostr notification yet. + coordinator.toggleFavorite(peerID: peerID) + #expect(context.addedFavorites.count == 1) + #expect(context.addedFavorites.first?.noiseKey == noiseKey) + #expect(context.addedFavorites.first?.nickname == "Unknown") + #expect(context.nostrFavoriteNotifications.isEmpty) + #expect(coordinator.isFavorite(peerID: peerID) == false) + + // They already favorite us: adding sends the mutual notification. + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + theyFavoritedUs: true + ) + coordinator.toggleFavorite(peerID: peerID) + #expect(context.addedFavorites.count == 2) + #expect(context.addedFavorites.last?.nickname == "alice") + #expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true]) + + // Existing favorite: toggling removes it and notifies the unfavorite. + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + isFavorite: true + ) + #expect(coordinator.isFavorite(peerID: peerID) == true) + coordinator.toggleFavorite(peerID: peerID) + #expect(context.removedFavorites == [noiseKey]) + #expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true, false]) + } + } diff --git a/bitchatTests/ChatPeerListCoordinatorContextTests.swift b/bitchatTests/ChatPeerListCoordinatorContextTests.swift index 85726978..67e433f5 100644 --- a/bitchatTests/ChatPeerListCoordinatorContextTests.swift +++ b/bitchatTests/ChatPeerListCoordinatorContextTests.swift @@ -7,10 +7,9 @@ // `ChatDeliveryCoordinatorContextTests` / // `ChatTransportEventCoordinatorContextTests` exemplars. // -// Scope note: the network-availability notification path posts through -// `NotificationService.shared` (a singleton) and arms wall-clock timers. These -// tests keep every peer mesh-inactive (`isPeerConnected`/`isPeerReachable` -// both false) so that path is never reached; the timer-driven reset flows are +// Scope note: the network-availability notification now posts through the +// injected `ChatPeerListContext` (`notifyNetworkAvailable(peerCount:)`), so +// its gating is covered here; the wall-clock timer-driven reset flows are // covered by integration-level tests. // @@ -54,6 +53,13 @@ private final class MockChatPeerListContext: ChatPeerListContext { func activeMeshPeerCount() -> Int { activeMeshPeerCountValue } func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) } func updateEncryptionStatusForPeers() { updateEncryptionStatusForPeersCount += 1 } + + // Notifications + private(set) var networkAvailableNotifications: [Int] = [] + + func notifyNetworkAvailable(peerCount: Int) { + networkAvailableNotifications.append(peerCount) + } } // MARK: - Helpers @@ -162,4 +168,40 @@ struct ChatPeerListCoordinatorContextTests { #expect(context.unreadPrivateMessages == [currentPeer, geoDMWithMessages, noiseKeyWithMessages]) #expect(context.cleanupOldReadReceiptsCount == 1) } + + @Test @MainActor + func didUpdatePeerList_notifiesNetworkAvailableOncePerCooldownForNewMeshPeers() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + let peerA = PeerID(str: "0011223344556677") + let peerB = PeerID(str: "8899aabbccddeeff") + context.connectedMeshPeers = [peerA, peerB] + + // First sighting of a mesh-active peer notifies with the mesh peer count. + coordinator.didUpdatePeerList([peerA]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + + // The same peer again is not new — no repeat notification. + coordinator.didUpdatePeerList([peerA]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + + // A genuinely new peer inside the cooldown window stays silent too. + coordinator.didUpdatePeerList([peerA, peerB]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications == [1]) + } + + @Test @MainActor + func didUpdatePeerList_meshInactivePeersNeverNotify() async { + let context = MockChatPeerListContext() + let coordinator = ChatPeerListCoordinator(context: context) + let peerA = PeerID(str: "0011223344556677") + + // Peer present but neither connected nor reachable: no notification. + coordinator.didUpdatePeerList([peerA]) + await drainMainActorTasks() + #expect(context.networkAvailableNotifications.isEmpty) + } } diff --git a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift index e8f9493e..7a6fcf67 100644 --- a/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPrivateConversationCoordinatorContextTests.swift @@ -132,6 +132,23 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC embeddedDeliveryAckMessageIDs.append(message.id) } + // Favorites & notifications + var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:] + private(set) var peerFavoritedUsUpdates: [(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)] = [] + private(set) var privateMessageNotifications: [(senderName: String, message: String, peerID: PeerID)] = [] + + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { + favoriteRelationshipsByNoiseKey[noiseKey] + } + + func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) { + peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey)) + } + + func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) { + privateMessageNotifications.append((senderName, message, peerID)) + } + // System messages & chat hygiene private(set) var systemMessages: [String] = [] private(set) var meshOnlySystemMessages: [String] = [] @@ -191,13 +208,33 @@ private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool { return false } +private func makeFavoriteRelationship( + noiseKey: Data, + nostrPublicKey: String? = nil, + nickname: String = "alice", + isFavorite: Bool = false, + theyFavoritedUs: Bool = false +) -> FavoritesPersistenceService.FavoriteRelationship { + FavoritesPersistenceService.FavoriteRelationship( + peerNoisePublicKey: noiseKey, + peerNostrPublicKey: nostrPublicKey, + peerNickname: nickname, + isFavorite: isFavorite, + theyFavoritedUs: theyFavoritedUs, + favoritedAt: Date(timeIntervalSince1970: 0), + lastUpdated: Date(timeIntervalSince1970: 0) + ) +} + // MARK: - Coordinator Tests Against Mock Context /// Exercises `ChatPrivateConversationCoordinator` against /// `MockChatPrivateConversationContext` with no `ChatViewModel`. Scoped to the -/// pure-state and ack flows; flows that hit `NotificationService` / -/// `FavoritesPersistenceService` singletons remain covered by the full -/// view-model tests. +/// pure-state and ack flows plus — now that notifications and favorites are +/// injected through the context (`notifyPrivateMessage`, +/// `favoriteRelationship(forNoiseKey:)`, `updatePeerFavoritedUs`) — the +/// notification and favorite-transition flows that previously required the +/// live singletons. struct ChatPrivateConversationCoordinatorContextTests { @Test @MainActor @@ -402,4 +439,99 @@ struct ChatPrivateConversationCoordinatorContextTests { #expect(context.sanitizedPeerIDs == [newPeerID]) #expect(context.notifyUIChangedCount == 1) } + + @Test @MainActor + func handlePrivateMessage_postsNotificationOnlyWhenNotViewingChat() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let peerID = PeerID(str: "0102030405060708") + + // Not viewing: marked unread and a local notification is posted. + coordinator.handlePrivateMessage( + makeIncomingMessage(id: "pm-1", content: "hi there", senderPeerID: peerID) + ) + #expect(context.unreadPrivateMessages == [peerID]) + #expect(context.privateMessageNotifications.count == 1) + #expect(context.privateMessageNotifications.first?.senderName == "alice") + #expect(context.privateMessageNotifications.first?.message == "hi there") + #expect(context.privateMessageNotifications.first?.peerID == peerID) + #expect(context.meshReadReceipts.isEmpty) + + // Viewing the chat: a READ ack is sent instead and no notification fires. + context.selectedPrivateChatPeer = peerID + coordinator.handlePrivateMessage(makeIncomingMessage(id: "pm-2", senderPeerID: peerID)) + #expect(context.meshReadReceipts.map(\.messageID) == ["pm-2"]) + #expect(context.sentReadReceipts.contains("pm-2")) + #expect(context.privateMessageNotifications.count == 1) + } + + @Test @MainActor + func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xAB, count: 32) + let peerID = PeerID(hexData: noiseKey) + + // First [FAVORITED] flips theyFavoritedUs: store write + announcement. + coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") + #expect(context.peerFavoritedUsUpdates.count == 1) + #expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey) + #expect(context.peerFavoritedUsUpdates.first?.favorited == true) + #expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice") + #expect(context.meshOnlySystemMessages == ["alice favorited you"]) + + // Same state again: store write still happens, but no repeat announcement. + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + theyFavoritedUs: true + ) + coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice") + #expect(context.peerFavoritedUsUpdates.count == 2) + #expect(context.meshOnlySystemMessages == ["alice favorited you"]) + + // [UNFAVORITED] transition announces again. + coordinator.handleFavoriteNotificationFromMesh("[UNFAVORITED]", from: peerID, senderNickname: "alice") + #expect(context.peerFavoritedUsUpdates.last?.favorited == false) + #expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"]) + } + + @Test @MainActor + func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let noiseKey = Data(repeating: 0xCD, count: 32) + let peerID = PeerID(hexData: noiseKey) + context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship( + noiseKey: noiseKey, + nostrPublicKey: "npub1bob", + nickname: "bob", + isFavorite: true, + theyFavoritedUs: true + ) + + coordinator.sendPrivateMessage("hello bob", to: peerID) + + // Offline but mutual favorite with a Nostr key: routed, marked sent, + // and the nickname falls back to the favorite relationship. + #expect(context.routedPrivateMessages.map(\.content) == ["hello bob"]) + #expect(context.privateChats[peerID]?.first?.deliveryStatus == .sent) + #expect(context.privateChats[peerID]?.first?.recipientNickname == "bob") + #expect(context.systemMessages.isEmpty) + } + + @Test @MainActor + func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async { + let context = MockChatPrivateConversationContext() + let coordinator = ChatPrivateConversationCoordinator(context: context) + let peerID = PeerID(hexData: Data(repeating: 0xCD, count: 32)) + + coordinator.sendPrivateMessage("hello?", to: peerID) + + #expect(context.routedPrivateMessages.isEmpty) + #expect(context.systemMessages.count == 1) + guard case .failed = context.privateChats[peerID]?.first?.deliveryStatus else { + Issue.record("expected .failed delivery status") + return + } + } } diff --git a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift index 6051ccca..fb661241 100644 --- a/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift +++ b/bitchatTests/ChatPublicConversationCoordinatorContextTests.swift @@ -7,11 +7,11 @@ // `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: flows that hit process-wide singletons are intentionally not -// exercised here — `checkForMentions` / haptics (NotificationService.shared, -// UIApplication) and the geohash branch of `sendPublicRaw` -// (NostrRelayManager.shared, GeoRelayDirectory.shared). The mesh branch of -// `sendPublicRaw` and all timeline/store/blocking flows are covered. +// Scope note: haptics (UIApplication) and the geohash branch of +// `sendPublicRaw` (NostrRelayManager.shared, GeoRelayDirectory.shared) are +// intentionally not exercised here. `checkForMentions` posts through the +// injected context (`notifyMention(from:message:)`) and is covered, as are +// the mesh branch of `sendPublicRaw` and all timeline/store/blocking flows. // import Testing @@ -249,6 +249,13 @@ private final class MockChatPublicConversationContext: ChatPublicConversationCon prewarmedMessageIDs.append(message.id) } + // Notifications + private(set) var mentionNotifications: [(sender: String, message: String)] = [] + + func notifyMention(from sender: String, message: String) { + mentionNotifications.append((sender, message)) + } + static let dummyIdentity = NostrIdentity( privateKey: Data(repeating: 0x11, count: 32), publicKey: Data(repeating: 0x22, count: 32), @@ -502,4 +509,51 @@ struct ChatPublicConversationCoordinatorContextTests { let unknownHex = String(repeating: "12", count: 32) #expect(coordinator.displayNameForNostrPubkey(unknownHex) == "anon#" + unknownHex.suffix(4)) } + @Test @MainActor + func checkForMentions_postsMentionNotificationOnlyForOthersMentioningMe() async { + let context = MockChatPublicConversationContext() + let coordinator = ChatPublicConversationCoordinator(context: context) + + // A mention of my nickname from someone else notifies. + coordinator.checkForMentions( + BitchatMessage( + id: "mention-1", + sender: "alice", + content: "hey @me", + timestamp: Date(), + isRelay: false, + mentions: ["me"] + ) + ) + #expect(context.mentionNotifications.count == 1) + #expect(context.mentionNotifications.first?.sender == "alice") + #expect(context.mentionNotifications.first?.message == "hey @me") + + // Mentioning someone else does not notify. + coordinator.checkForMentions( + BitchatMessage( + id: "mention-2", + sender: "alice", + content: "hey @bob", + timestamp: Date(), + isRelay: false, + mentions: ["bob"] + ) + ) + #expect(context.mentionNotifications.count == 1) + + // My own message mentioning myself does not notify. + coordinator.checkForMentions( + BitchatMessage( + id: "mention-3", + sender: "me", + content: "talking about @me", + timestamp: Date(), + isRelay: false, + mentions: ["me"] + ) + ) + #expect(context.mentionNotifications.count == 1) + } + } diff --git a/bitchatTests/ChatVerificationCoordinatorContextTests.swift b/bitchatTests/ChatVerificationCoordinatorContextTests.swift index 8dff14c3..4ef7f9f7 100644 --- a/bitchatTests/ChatVerificationCoordinatorContextTests.swift +++ b/bitchatTests/ChatVerificationCoordinatorContextTests.swift @@ -7,11 +7,12 @@ // `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` / // `ChatPrivateConversationCoordinatorContextTests` exemplars. // -// Scope note: `handleVerifyResponsePayload` requires a real Ed25519 signature -// and posts via `NotificationService.shared`; it remains covered by the full -// view-model/integration tests. Challenge handling, QR kickoff, fingerprint -// verification, and verified-set loading are covered here -// (`VerificationService.shared` is only used for pure payload build/parse). +// Scope note: `handleVerifyResponsePayload` requires a real Ed25519 +// signature; it remains covered by the full view-model/integration tests. +// Challenge handling, QR kickoff, fingerprint verification, verified-set +// loading, and the mutual-verification notification (posted through the +// injected context) are covered here (`VerificationService.shared` is only +// used for pure payload build/parse). // import Testing @@ -117,6 +118,13 @@ private final class MockChatVerificationContext: ChatVerificationContext { func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { sentResponses.append((peerID, noiseKeyHex, nonceA)) } + + // Notifications + private(set) var postedLocalNotifications: [(title: String, body: String, identifier: String)] = [] + + func postLocalNotification(title: String, body: String, identifier: String) { + postedLocalNotifications.append((title, body, identifier)) + } } // MARK: - Helpers @@ -272,6 +280,35 @@ struct ChatVerificationCoordinatorContextTests { await waitForMainQueue() #expect(context.encryptionStatuses[peerID] == .noiseHandshaking) } + + @Test @MainActor + func handleVerifyChallengePayload_postsMutualVerificationToastOncePerMinute() async { + let context = MockChatVerificationContext() + let coordinator = ChatVerificationCoordinator(context: context) + let peerID = PeerID(str: "1122334455667788") + let myHex = context.myNoiseStaticKey.hexEncodedString() + context.fingerprintsByPeerID[peerID] = "fp-mutual" + context.verifiedFingerprints = ["fp-mutual"] + + coordinator.handleVerifyChallengePayload( + from: peerID, + payload: makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: Data(repeating: 0x07, count: 16)) + ) + + // Already-verified peer challenging us: mutual-verification toast. + #expect(context.postedLocalNotifications.count == 1) + #expect(context.postedLocalNotifications.first?.title == "Mutual verification") + #expect(context.postedLocalNotifications.first?.body.hasSuffix("verified each other") == true) + #expect(context.postedLocalNotifications.first?.identifier.hasPrefix("verify-mutual-") == true) + + // A fresh nonce inside the per-fingerprint toast cooldown stays silent. + coordinator.handleVerifyChallengePayload( + from: peerID, + payload: makeVerifyChallengeTLV(noiseKeyHex: myHex, nonceA: Data(repeating: 0x08, count: 16)) + ) + #expect(context.postedLocalNotifications.count == 1) + #expect(context.sentResponses.count == 2) + } } /// The installed callbacks hop through `DispatchQueue.main.async`; tests must diff --git a/bitchatTests/ChatViewModelExtensionsTests.swift b/bitchatTests/ChatViewModelExtensionsTests.swift index c4875321..b275358c 100644 --- a/bitchatTests/ChatViewModelExtensionsTests.swift +++ b/bitchatTests/ChatViewModelExtensionsTests.swift @@ -398,40 +398,6 @@ struct ChatViewModelNostrExtensionTests { #expect(viewModel.privateChats.isEmpty) } - @Test @MainActor - func handleNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws { - let (viewModel, _) = makeTestableViewModel() - let sender = try NostrIdentity.generate() - let recipient = try #require(try viewModel.idBridge.getCurrentNostrIdentity()) - let giftWrap = try NostrProtocol.createPrivateMessage( - content: "verify:noop", - recipientPubkey: recipient.publicKeyHex, - senderIdentity: sender - ) - var invalidGiftWrap = giftWrap - invalidGiftWrap.sig = String(repeating: "0", count: 128) - - // Verification and recording now happen on a detached task; give the - // invalid copy time to be verified-and-rejected, then assert it never - // entered the dedup set. - viewModel.handleNostrMessage(invalidGiftWrap) - try await Task.sleep(nanoseconds: 150_000_000) - #expect(!viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id)) - - // Generous deadline: the detached verification task can be starved - // under parallel test load. - viewModel.handleNostrMessage(giftWrap) - var recorded = false - for _ in 0..<600 { - if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { - recorded = true - break - } - try await Task.sleep(nanoseconds: 10_000_000) - } - #expect(recorded) - } - @Test @MainActor func switchLocationChannel_clearsNostrDedupCache() async { let (viewModel, _) = makeTestableViewModel() diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 332489a6..c598d201 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -459,6 +459,12 @@ private final class PerfNostrContext: ChatNostrContext { func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {} func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {} func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {} + + func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? { nil } + func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] { [] } + func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {} + func postLocalNotification(title: String, body: String, identifier: String) {} + func notifyGeohashActivity(geohash: String, bodyPreview: String) {} } // MARK: - Mock ChatDeliveryContext diff --git a/bitchatTests/SchnorrConcurrencyRepro.swift b/bitchatTests/SchnorrConcurrencyRepro.swift new file mode 100644 index 00000000..47ef9375 --- /dev/null +++ b/bitchatTests/SchnorrConcurrencyRepro.swift @@ -0,0 +1,82 @@ +import Testing +import Foundation +@testable import bitchat + +struct SchnorrConcurrencyRepro { + @Test func concurrentVerificationOfValidEventAlwaysSucceeds() async throws { + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [["g", "sn"]], + content: "concurrency probe" + ) + let signed = try event.sign(with: identity.schnorrSigningKey()) + #expect(signed.isValidSignature()) + + let failures = await withTaskGroup(of: Int.self) { group in + for _ in 0..<8 { + group.addTask { + var localFailures = 0 + for _ in 0..<250 { + if !signed.isValidSignature() { localFailures += 1 } + } + return localFailures + } + } + var total = 0 + for await f in group { total += f } + return total + } + #expect(failures == 0, "Schnorr verification returned false \(failures)/2000 times under concurrency") + } + + @Test func verificationSurvivesConcurrentSigningAndKeyGeneration() async throws { + let identity = try NostrIdentity.generate() + let event = NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [["g", "sn"]], + content: "concurrency probe" + ) + let signed = try event.sign(with: identity.schnorrSigningKey()) + #expect(signed.isValidSignature()) + + // Signing and key generation may mutate shared library state + // (secp256k1 context randomization); verification must stay correct + // while they run on other tasks. + let failures = await withTaskGroup(of: Int.self) { group in + for worker in 0..<8 { + group.addTask { + if worker < 4 { + var localFailures = 0 + for _ in 0..<250 { + if !signed.isValidSignature() { localFailures += 1 } + } + return localFailures + } else { + for i in 0..<100 { + if let id = try? NostrIdentity.generate() { + let e = NostrEvent( + pubkey: id.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [], + content: "signer \(worker)-\(i)" + ) + _ = try? e.sign(with: id.schnorrSigningKey()) + } + } + return 0 + } + } + } + var total = 0 + for await f in group { total += f } + return total + } + #expect(failures == 0, "Schnorr verification returned false \(failures)/1000 times while signing ran concurrently") + } +}