diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index d796e601..9df7e583 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -58,6 +58,8 @@ 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; }; ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; }; ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; }; + ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; }; + ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; }; 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; }; 0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; @@ -216,6 +218,7 @@ 049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = ""; }; 049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = ""; }; ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceiptTracker.swift; sourceTree = ""; }; + ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrInboxService.swift; sourceTree = ""; }; 049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = ""; }; 049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = ""; }; 049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = ""; }; @@ -524,6 +527,7 @@ children = ( 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */, ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */, + ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */, AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, 047502B82E560F690083520F /* RelayController.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */, @@ -748,6 +752,7 @@ files = ( 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */, ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */, + ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */, 1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */, AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */, AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */, @@ -808,6 +813,7 @@ files = ( 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */, ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */, + ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */, 1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */, AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */, AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */, diff --git a/bitchat/Services/NostrInboxService.swift b/bitchat/Services/NostrInboxService.swift new file mode 100644 index 00000000..96505531 --- /dev/null +++ b/bitchat/Services/NostrInboxService.swift @@ -0,0 +1,90 @@ +// +// NostrInboxService.swift +// bitchat +// +// Centralizes Nostr subscribe/unsubscribe and event de-duplication for inbox flows. +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +final class NostrInboxService { + private var processedIDs: Set = [] + private var order: [String] = [] + private let cap: Int + + init(capacity: Int = TransportConfig.uiProcessedNostrEventsCap) { + self.cap = capacity + } + + private func track(_ id: String) -> Bool { + if processedIDs.contains(id) { return false } + processedIDs.insert(id) + order.append(id) + if order.count > cap { + let overflow = order.count - cap + for _ in 0.. Void + ) -> String { + let id = subID ?? "geo-\(geohash)" + let filter = NostrFilter.geohashEphemeral( + geohash, + since: Date().addingTimeInterval(-lookbackSeconds), + limit: limit + ) + let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: relayCount) + NostrRelayManager.shared.subscribe(filter: filter, id: id, relayUrls: relays) { [weak self] event in + guard let self = self else { return } + guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + guard self.track(event.id) else { return } + handler(event) + } + return id + } + + // Subscribe to gift wraps (DMs) for a given pubkey + @discardableResult + func subscribeGiftWrapsFor( + pubkeyHex: String, + lookbackSeconds: TimeInterval, + subID: String? = nil, + handler: @escaping (NostrProtocol.Event) -> Void + ) -> String { + let id = subID ?? "dm-\(pubkeyHex.prefix(8))" + let filter = NostrFilter.giftWrapsFor(pubkey: pubkeyHex, since: Date().addingTimeInterval(-lookbackSeconds)) + NostrRelayManager.shared.subscribe(filter: filter, id: id) { [weak self] event in + guard let self = self else { return } + guard self.track(event.id) else { return } + handler(event) + } + return id + } + + func unsubscribe(id: String) { + NostrRelayManager.shared.unsubscribe(id: id) + } +} + diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 433dece4..72f85f31 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -348,9 +348,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { var meshService: Transport = BLEService() private var nostrRelayManager: NostrRelayManager? // PeerManager replaced by UnifiedPeerService - private var processedNostrEvents = Set() // Simple deduplication - private var processedNostrEventOrder: [String] = [] - private let maxProcessedNostrEvents = TransportConfig.uiProcessedNostrEventsCap + // NostrInboxService handles event de-duplication private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" // Location channel state (macOS supports manual geohash selection) @@ -359,6 +357,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { private var geoDmSubscriptionID: String? = nil private var currentGeohash: String? = nil private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname + private let nostrInbox = NostrInboxService() // MARK: - Caches @@ -901,20 +900,15 @@ class ChatViewModel: ObservableObject, BitchatDelegate { startGeoParticipantsTimer() // Unsubscribe + resubscribe NostrRelayManager.shared.unsubscribe(id: subID) - let filter = NostrFilter.geohashEphemeral( - ch.geohash, - since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), - limit: TransportConfig.nostrGeohashInitialLimit - ) - let subRelays = GeoRelayDirectory.shared.closestRelays( - toGeohash: ch.geohash, - count: TransportConfig.nostrGeoRelayCount - ) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + nostrInbox.subscribeGeohashEphemeral( + geohash: ch.geohash, + lookbackSeconds: TransportConfig.nostrGeohashInitialLookbackSeconds, + limit: TransportConfig.nostrGeohashInitialLimit, + relayCount: TransportConfig.nostrGeoRelayCount, + subID: subID + ) { [weak self] event in guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } - if self.processedNostrEvents.contains(event.id) { return } - self.processedNostrEvents.insert(event.id) if let gh = self.currentGeohash, let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { @@ -971,11 +965,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) let dmSub = "geo-dm-\(ch.geohash)" geoDmSubscriptionID = dmSub - let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) - NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + nostrInbox.subscribeGiftWrapsFor( + pubkeyHex: id.publicKeyHex, + lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds, + subID: dmSub + ) { [weak self] giftWrap in guard let self = self else { return } - if self.processedNostrEvents.contains(giftWrap.id) { return } - self.recordProcessedEvent(giftWrap.id) guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { return } guard content.hasPrefix("bitchat1:") else { return } guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), @@ -1512,8 +1507,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { publicBuffer.removeAll(keepingCapacity: false) activeChannel = channel // Reset deduplication set and optionally hydrate timeline for mesh - processedNostrEvents.removeAll() - processedNostrEventOrder.removeAll() + nostrInbox.reset() switch channel { case .mesh: messages = meshTimeline @@ -1557,19 +1551,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let subID = "geo-\(ch.geohash)" geoSubscriptionID = subID startGeoParticipantsTimer() - let filter = NostrFilter.geohashEphemeral( - ch.geohash, - since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds), - limit: TransportConfig.nostrGeohashInitialLimit - ) - let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in + nostrInbox.subscribeGeohashEphemeral( + geohash: ch.geohash, + lookbackSeconds: TransportConfig.nostrGeohashInitialLookbackSeconds, + limit: TransportConfig.nostrGeohashInitialLimit, + relayCount: TransportConfig.nostrGeoRelayCount, + subID: subID + ) { [weak self] event in guard let self = self else { return } // Only handle ephemeral kind 20000 with matching tag guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } - // Deduplicate - if self.processedNostrEvents.contains(event.id) { return } - self.recordProcessedEvent(event.id) // Log incoming tags for diagnostics let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") SecureLogger.log("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", @@ -1646,12 +1637,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // pared back logging: subscribe debug only SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", category: SecureLogger.session, level: .debug) - let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)) - NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + nostrInbox.subscribeGiftWrapsFor(pubkeyHex: id.publicKeyHex, lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds, subID: dmSub) { [weak self] giftWrap in guard let self = self else { return } - // Dedup basic - if self.processedNostrEvents.contains(giftWrap.id) { return } - self.recordProcessedEvent(giftWrap.id) // Decrypt with per-geohash identity guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { SecureLogger.log("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", @@ -1918,7 +1905,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) { - NostrRelayManager.shared.unsubscribe(id: subID) + nostrInbox.unsubscribe(id: subID) geoSamplingSubs.removeValue(forKey: subID) } @@ -1926,13 +1913,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { for gh in toAdd { let subID = "geo-sample-\(gh)" 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 + nostrInbox.subscribeGeohashEphemeral( + geohash: gh, + lookbackSeconds: TransportConfig.nostrGeohashSampleLookbackSeconds, + limit: TransportConfig.nostrGeohashSampleLimit, + relayCount: TransportConfig.nostrGeoRelayCount, + subID: subID + ) { [weak self] event in guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } // Update participants for this specific geohash @@ -2008,19 +1995,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } // Dedup helper with small memory cap - private func recordProcessedEvent(_ id: String) { - processedNostrEvents.insert(id) - processedNostrEventOrder.append(id) - if processedNostrEventOrder.count > maxProcessedNostrEvents { - let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents - for _ in 0..