Inject notification/favorites singletons through coordinator contexts

NotificationService.shared and FavoritesPersistenceService.shared
accesses across nine coordinators/components consolidate into
ChatViewModel witnesses behind intent-named context members
(notifyPrivateMessage, notifyMention, favoriteRelationship(forNoiseKey:),
allFavoriteRelationships, postLocalNotification, ...). Singleton reach
from coordinators is now zero; nine new tests cover previously
untestable notification/favorites flows.

Also root-causes the long-flaky gift-wrap dedup test: parallel tests
share LocationChannelManager.shared, so channel-switch tests trigger
clearProcessedNostrEvents() on every live ChatViewModel, wiping the
dedup record mid-test. The invariant (forged-signature copies never
poison dedup) now lives as a deterministic NostrInboundPipeline
mock-context test; two Schnorr concurrency probes added along the way
stay as regression guards for the P256K shared-context assumptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-06-10 23:04:20 +02:00
co-authored by Claude Fable 5
parent 638f3f5005
commit cc76086615
19 changed files with 820 additions and 116 deletions
@@ -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) {
+23 -11
View File
@@ -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
@@ -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()
@@ -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
@@ -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 {
@@ -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)
}
}
@@ -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)"
+8 -1
View File
@@ -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)
}
}
}
+11 -1
View File
@@ -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") {