mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 01:45:20 +00:00
refactor(nostr): add NostrInboxService and route geohash/DM subscriptions through it; move de-dup away from ChatViewModel
- Introduce Services/NostrInboxService to centralize subscribe/unsubscribe + event de-dup.\n- Wire ChatViewModel resubscribeCurrentGeohash, switchLocationChannel, sampling, and global DM subscribe to use the service.\n- Remove ChatViewModel's local processedNostrEvents tracking.
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; };
|
||||
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceiptTracker.swift; sourceTree = "<group>"; };
|
||||
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrInboxService.swift; sourceTree = "<group>"; };
|
||||
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; };
|
||||
049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; };
|
||||
049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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 <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class NostrInboxService {
|
||||
private var processedIDs: Set<String> = []
|
||||
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..<overflow {
|
||||
if let oldest = order.first {
|
||||
order.removeFirst()
|
||||
processedIDs.remove(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func reset() {
|
||||
processedIDs.removeAll()
|
||||
order.removeAll()
|
||||
}
|
||||
|
||||
// Subscribe to public geohash ephemeral events (kind 20000)
|
||||
@discardableResult
|
||||
func subscribeGeohashEphemeral(
|
||||
geohash: String,
|
||||
lookbackSeconds: TimeInterval,
|
||||
limit: Int,
|
||||
relayCount: Int,
|
||||
subID: String? = nil,
|
||||
handler: @escaping (NostrProtocol.Event) -> 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,9 +348,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
var meshService: Transport = BLEService()
|
||||
private var nostrRelayManager: NostrRelayManager?
|
||||
// PeerManager replaced by UnifiedPeerService
|
||||
private var processedNostrEvents = Set<String>() // 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..<overflow {
|
||||
if let old = processedNostrEventOrder.first {
|
||||
processedNostrEventOrder.removeFirst()
|
||||
processedNostrEvents.remove(old)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private func recordProcessedEvent(_ id: String) {}
|
||||
|
||||
/// Sends an encrypted private message to a specific peer.
|
||||
/// - Parameters:
|
||||
@@ -4939,12 +4914,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Subscribe to Nostr messages
|
||||
let filter = NostrFilter.giftWrapsFor(
|
||||
pubkey: currentIdentity.publicKeyHex,
|
||||
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) // Last 24 hours
|
||||
)
|
||||
|
||||
nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||
let _ = nostrInbox.subscribeGiftWrapsFor(
|
||||
pubkeyHex: currentIdentity.publicKeyHex,
|
||||
lookbackSeconds: TransportConfig.nostrDMSubscribeLookbackSeconds,
|
||||
subID: "chat-messages"
|
||||
) { [weak self] event in
|
||||
Task { @MainActor in
|
||||
self?.handleNostrMessage(event)
|
||||
}
|
||||
@@ -4953,9 +4927,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
private func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
// Simple deduplication
|
||||
if processedNostrEvents.contains(giftWrap.id) { return }
|
||||
processedNostrEvents.insert(giftWrap.id)
|
||||
// De-duplication handled by NostrInboxService
|
||||
|
||||
// Client-side filtering: ignore messages older than 24 hours
|
||||
// Add 15 minutes buffer since gift wrap timestamps are randomized ±15 minutes
|
||||
|
||||
Reference in New Issue
Block a user