mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
Split ChatNostrCoordinator into owned components along domain boundaries
The 1,109-line coordinator becomes a 187-line facade wiring three components, each with its own narrow context protocol: GeohashSubscriptionManager (384 lines - subscription IDs + relay lifecycle, the only NostrRelayManager toucher), NostrInboundPipeline (490 lines - the hot event path, dedup-before-verify ordering preserved verbatim), and GeoPresenceTracker (192 lines - teleport detection, sampling LRU, notification cooldowns, now directly tested). Perf baselines confirm the hot path is unchanged: fresh events 2,131 -> 2,138/sec, duplicates ~1.41M/sec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -12,86 +12,86 @@ extension ChatViewModel {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func resubscribeCurrentGeohash() {
|
func resubscribeCurrentGeohash() {
|
||||||
nostrCoordinator.resubscribeCurrentGeohash()
|
nostrCoordinator.subscriptions.resubscribeCurrentGeohash()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||||
nostrCoordinator.subscribeNostrEvent(event)
|
nostrCoordinator.inbound.subscribeNostrEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
nostrCoordinator.subscribeGiftWrap(giftWrap, id: id)
|
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func switchLocationChannel(to channel: ChannelID) {
|
func switchLocationChannel(to channel: ChannelID) {
|
||||||
nostrCoordinator.switchLocationChannel(to: channel)
|
nostrCoordinator.subscriptions.switchLocationChannel(to: channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleNostrEvent(_ event: NostrEvent) {
|
func handleNostrEvent(_ event: NostrEvent) {
|
||||||
nostrCoordinator.handleNostrEvent(event)
|
nostrCoordinator.inbound.handleNostrEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeToGeoChat(_ ch: GeohashChannel) {
|
func subscribeToGeoChat(_ ch: GeohashChannel) {
|
||||||
nostrCoordinator.subscribeToGeoChat(ch)
|
nostrCoordinator.subscriptions.subscribeToGeoChat(ch)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
nostrCoordinator.handleGiftWrap(giftWrap, id: id)
|
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func sendGeohash(context: GeoOutgoingContext) {
|
func sendGeohash(context: GeoOutgoingContext) {
|
||||||
nostrCoordinator.sendGeohash(context: context)
|
nostrCoordinator.subscriptions.sendGeohash(context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func beginGeohashSampling(for geohashes: [String]) {
|
func beginGeohashSampling(for geohashes: [String]) {
|
||||||
nostrCoordinator.beginGeohashSampling(for: geohashes)
|
nostrCoordinator.subscriptions.beginGeohashSampling(for: geohashes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribe(_ gh: String) {
|
func subscribe(_ gh: String) {
|
||||||
nostrCoordinator.subscribe(gh)
|
nostrCoordinator.subscriptions.subscribe(gh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||||
nostrCoordinator.subscribeNostrEvent(event, gh: gh)
|
nostrCoordinator.presence.subscribeNostrEvent(event, gh: gh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
||||||
nostrCoordinator.cooldownPerGeohash(gh, content: content, event: event)
|
nostrCoordinator.presence.cooldownPerGeohash(gh, content: content, event: event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func endGeohashSampling() {
|
func endGeohashSampling() {
|
||||||
nostrCoordinator.endGeohashSampling()
|
nostrCoordinator.subscriptions.endGeohashSampling()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func setupNostrMessageHandling() {
|
func setupNostrMessageHandling() {
|
||||||
nostrCoordinator.setupNostrMessageHandling()
|
nostrCoordinator.subscriptions.setupNostrMessageHandling()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||||
nostrCoordinator.handleNostrMessage(giftWrap)
|
nostrCoordinator.inbound.handleNostrMessage(giftWrap)
|
||||||
}
|
}
|
||||||
|
|
||||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||||
await nostrCoordinator.processNostrMessage(giftWrap)
|
await nostrCoordinator.inbound.processNostrMessage(giftWrap)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||||
nostrCoordinator.findNoiseKey(for: nostrPubkey)
|
nostrCoordinator.inbound.findNoiseKey(for: nostrPubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The narrow surface `GeoPresenceTracker` needs from its owner.
|
||||||
|
///
|
||||||
|
/// Split out of `ChatNostrContext`: member names are shared with the sibling
|
||||||
|
/// component contexts so `ChatViewModel` provides a single witness for each.
|
||||||
|
@MainActor
|
||||||
|
protocol GeoPresenceContext: AnyObject {
|
||||||
|
var activeChannel: ChannelID { get }
|
||||||
|
/// Per-geohash notification cooldown: geohash -> last notify time.
|
||||||
|
var lastGeoNotificationAt: [String: Date] { get set }
|
||||||
|
var geoNicknames: [String: String] { get }
|
||||||
|
var teleportedGeoCount: Int { get }
|
||||||
|
|
||||||
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
||||||
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||||
|
func parseMentions(from content: String) -> [String]
|
||||||
|
|
||||||
|
func recordGeoParticipant(pubkeyHex: String, geohash: String)
|
||||||
|
func geoParticipantCount(for geohash: String) -> Int
|
||||||
|
func markGeoTeleported(_ pubkeyHexLowercased: String)
|
||||||
|
|
||||||
|
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
|
||||||
|
func synchronizePublicConversationStore(forGeohash geohash: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ChatViewModel: GeoPresenceContext {
|
||||||
|
// `activeChannel`, `lastGeoNotificationAt`, `geoNicknames`, the Nostr
|
||||||
|
// identity/blocking members, and `synchronizePublicConversationStore`
|
||||||
|
// already have witnesses on `ChatViewModel`. The members below flatten
|
||||||
|
// nested service accesses into intent-named calls.
|
||||||
|
|
||||||
|
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
|
||||||
|
timelineStore.appendIfAbsent(message, toGeohash: geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
var teleportedGeoCount: Int {
|
||||||
|
locationPresenceStore.teleportedGeo.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordGeoParticipant(pubkeyHex: String, geohash: String) {
|
||||||
|
participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func geoParticipantCount(for geohash: String) -> Int {
|
||||||
|
participantTracker.participantCount(for: geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markGeoTeleported(_ pubkeyHexLowercased: String) {
|
||||||
|
locationPresenceStore.markTeleported(pubkeyHexLowercased)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Geohash presence bookkeeping that is independent of relay subscriptions:
|
||||||
|
/// teleport-tag detection and marking, the sampling-event LRU dedup, and the
|
||||||
|
/// per-geohash notification cooldown for sampled activity.
|
||||||
|
final class GeoPresenceTracker {
|
||||||
|
private weak var context: (any GeoPresenceContext)?
|
||||||
|
private var recentGeoSamplingEventIDs = Set<String>()
|
||||||
|
private var recentGeoSamplingEventIDOrder: [String] = []
|
||||||
|
|
||||||
|
init(context: any GeoPresenceContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the event carries a `["t", "teleport"]` tag.
|
||||||
|
static func hasTeleportTag(_ event: NostrEvent) -> Bool {
|
||||||
|
event.tags.contains { tag in
|
||||||
|
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a peer teleported on a follow-up main-actor hop (keeps the
|
||||||
|
/// inbound hot path free of presence-store writes).
|
||||||
|
@MainActor
|
||||||
|
func scheduleMarkPeerTeleported(_ key: String, logged: Bool) {
|
||||||
|
Task { @MainActor [weak context] in
|
||||||
|
guard let context else { return }
|
||||||
|
context.markGeoTeleported(key)
|
||||||
|
if logged {
|
||||||
|
SecureLogger.info(
|
||||||
|
"GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||||
|
guard let context else { return }
|
||||||
|
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||||
|
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard event.isValidSignature() else { return }
|
||||||
|
guard shouldProcessGeoSamplingEvent(event.id) else { return }
|
||||||
|
|
||||||
|
let existingCount = context.geoParticipantCount(for: gh)
|
||||||
|
context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||||
|
|
||||||
|
guard let content = event.content.trimmedOrNilIfEmpty else { return }
|
||||||
|
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||||
|
if let my = try? context.deriveNostrIdentity(forGeohash: gh),
|
||||||
|
my.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard existingCount == 0 else { return }
|
||||||
|
|
||||||
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
if Date().timeIntervalSince(eventTime) > 30 { return }
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
guard UIApplication.shared.applicationState == .active else { return }
|
||||||
|
if case .location(let channel) = context.activeChannel, channel.geohash == gh { return }
|
||||||
|
#elseif os(macOS)
|
||||||
|
guard NSApplication.shared.isActive else { return }
|
||||||
|
if case .location(let channel) = context.activeChannel, channel.geohash == gh { return }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
cooldownPerGeohash(gh, content: content, event: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
||||||
|
guard let context else { return }
|
||||||
|
let now = Date()
|
||||||
|
let last = context.lastGeoNotificationAt[gh] ?? .distantPast
|
||||||
|
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
|
||||||
|
|
||||||
|
let preview: String = {
|
||||||
|
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
|
||||||
|
if content.count <= maxLen { return content }
|
||||||
|
let idx = content.index(content.startIndex, offsetBy: maxLen)
|
||||||
|
return String(content[..<idx]) + "…"
|
||||||
|
}()
|
||||||
|
|
||||||
|
Task { @MainActor [weak context] in
|
||||||
|
guard let context else { return }
|
||||||
|
context.lastGeoNotificationAt[gh] = now
|
||||||
|
let senderSuffix = String(event.pubkey.suffix(4))
|
||||||
|
let nick = context.geoNicknames[event.pubkey.lowercased()]
|
||||||
|
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
||||||
|
|
||||||
|
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
let ts = min(rawTs, Date())
|
||||||
|
let mentions = context.parseMentions(from: content)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: event.id,
|
||||||
|
sender: senderName,
|
||||||
|
content: content,
|
||||||
|
timestamp: ts,
|
||||||
|
isRelay: false,
|
||||||
|
senderPeerID: PeerID(nostr: event.pubkey),
|
||||||
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
|
)
|
||||||
|
if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) {
|
||||||
|
context.synchronizePublicConversationStore(forGeohash: gh)
|
||||||
|
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First-seen check for sampled geohash events with LRU eviction so the
|
||||||
|
/// dedup set stays bounded across long sampling sessions.
|
||||||
|
func shouldProcessGeoSamplingEvent(_ eventID: String) -> Bool {
|
||||||
|
guard !eventID.isEmpty else { return true }
|
||||||
|
guard recentGeoSamplingEventIDs.insert(eventID).inserted else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
recentGeoSamplingEventIDOrder.append(eventID)
|
||||||
|
|
||||||
|
let cap = TransportConfig.geoSamplingEventLRUCap
|
||||||
|
if recentGeoSamplingEventIDOrder.count > cap {
|
||||||
|
let removeCount = recentGeoSamplingEventIDOrder.count - cap
|
||||||
|
for staleID in recentGeoSamplingEventIDOrder.prefix(removeCount) {
|
||||||
|
recentGeoSamplingEventIDs.remove(staleID)
|
||||||
|
}
|
||||||
|
recentGeoSamplingEventIDOrder.removeFirst(removeCount)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearGeoSamplingEventDedup() {
|
||||||
|
recentGeoSamplingEventIDs.removeAll()
|
||||||
|
recentGeoSamplingEventIDOrder.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
import Tor
|
||||||
|
|
||||||
|
/// The narrow surface `GeohashSubscriptionManager` needs from its owner.
|
||||||
|
///
|
||||||
|
/// Split out of `ChatNostrContext`: member names are shared with the sibling
|
||||||
|
/// component contexts so `ChatViewModel` provides a single witness for each.
|
||||||
|
@MainActor
|
||||||
|
protocol GeohashSubscriptionContext: AnyObject {
|
||||||
|
// MARK: Channel & subscription state
|
||||||
|
var activeChannel: ChannelID { get set }
|
||||||
|
var currentGeohash: String? { get set }
|
||||||
|
var geoSubscriptionID: String? { get }
|
||||||
|
var geoDmSubscriptionID: String? { get }
|
||||||
|
func setGeoChatSubscriptionID(_ id: String?)
|
||||||
|
func setGeoDmSubscriptionID(_ id: String?)
|
||||||
|
/// Geohash sampling subscriptions: subscription ID -> geohash.
|
||||||
|
var geoSamplingSubs: [String: String] { get }
|
||||||
|
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String)
|
||||||
|
func removeGeoSamplingSub(_ subID: String)
|
||||||
|
/// Clears all sampling subscriptions and returns the removed subscription IDs
|
||||||
|
/// so the caller can unsubscribe them from the relay manager.
|
||||||
|
func clearGeoSamplingSubs() -> [String]
|
||||||
|
var nostrRelayManager: NostrRelayManager? { get }
|
||||||
|
|
||||||
|
// MARK: Public timeline & pipeline
|
||||||
|
var messages: [BitchatMessage] { get }
|
||||||
|
func resetPublicMessagePipeline()
|
||||||
|
func updatePublicMessagePipelineChannel(_ channel: ChannelID)
|
||||||
|
func refreshVisibleMessages(from channel: ChannelID?)
|
||||||
|
func addPublicSystemMessage(_ content: String)
|
||||||
|
func drainPendingGeohashSystemMessages() -> [String]
|
||||||
|
|
||||||
|
// MARK: Nostr identity & dedup
|
||||||
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
||||||
|
func currentNostrIdentity() -> NostrIdentity?
|
||||||
|
func recordProcessedNostrEvent(_ eventID: String)
|
||||||
|
func clearProcessedNostrEvents()
|
||||||
|
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
|
||||||
|
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
|
||||||
|
|
||||||
|
// MARK: Geo participants & presence
|
||||||
|
var teleportedGeoCount: Int { get }
|
||||||
|
func startGeoParticipantRefreshTimer()
|
||||||
|
func stopGeoParticipantRefreshTimer()
|
||||||
|
func setActiveParticipantGeohash(_ geohash: String?)
|
||||||
|
func recordGeoParticipant(pubkeyHex: String)
|
||||||
|
func markGeoTeleported(_ pubkeyHexLowercased: String)
|
||||||
|
func clearGeoTeleported(_ pubkeyHexLowercased: String)
|
||||||
|
func clearTeleportedGeo()
|
||||||
|
func clearGeoNicknames()
|
||||||
|
|
||||||
|
// MARK: Location channels
|
||||||
|
var isTeleported: Bool { get }
|
||||||
|
/// True when regional channels are known and the geohash is not one of them.
|
||||||
|
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ChatViewModel: GeohashSubscriptionContext {
|
||||||
|
// `activeChannel`, `currentGeohash`, the subscription-ID accessors, the
|
||||||
|
// identity members, and the timeline members already have witnesses on
|
||||||
|
// `ChatViewModel`. The members below flatten nested service accesses into
|
||||||
|
// intent-named calls.
|
||||||
|
|
||||||
|
func resetPublicMessagePipeline() {
|
||||||
|
publicMessagePipeline.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePublicMessagePipelineChannel(_ channel: ChannelID) {
|
||||||
|
publicMessagePipeline.updateActiveChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func drainPendingGeohashSystemMessages() -> [String] {
|
||||||
|
timelineStore.drainPendingGeohashSystemMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearProcessedNostrEvents() {
|
||||||
|
deduplicationService.clearNostrCaches()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startGeoParticipantRefreshTimer() {
|
||||||
|
participantTracker.startRefreshTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopGeoParticipantRefreshTimer() {
|
||||||
|
participantTracker.stopRefreshTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setActiveParticipantGeohash(_ geohash: String?) {
|
||||||
|
participantTracker.setActiveGeohash(geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearGeoTeleported(_ pubkeyHexLowercased: String) {
|
||||||
|
locationPresenceStore.clearTeleported(pubkeyHexLowercased)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearTeleportedGeo() {
|
||||||
|
locationPresenceStore.clearTeleportedGeo()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearGeoNicknames() {
|
||||||
|
locationPresenceStore.clearGeoNicknames()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isTeleported: Bool {
|
||||||
|
locationManager.teleported
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool {
|
||||||
|
let channels = locationManager.availableChannels
|
||||||
|
return !channels.isEmpty && !channels.contains { $0.geohash == geohash }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash
|
||||||
|
/// DMs, the account gift-wrap mailbox, and background geohash sampling. The
|
||||||
|
/// only component that talks to `NostrRelayManager`; inbound events are
|
||||||
|
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
|
||||||
|
final class GeohashSubscriptionManager {
|
||||||
|
private weak var context: (any GeohashSubscriptionContext)?
|
||||||
|
private let inbound: NostrInboundPipeline
|
||||||
|
private let presence: GeoPresenceTracker
|
||||||
|
|
||||||
|
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
|
||||||
|
self.context = context
|
||||||
|
self.inbound = inbound
|
||||||
|
self.presence = presence
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func resubscribeCurrentGeohash() {
|
||||||
|
guard let context else { return }
|
||||||
|
guard case .location(let channel) = context.activeChannel else { return }
|
||||||
|
guard let subID = context.geoSubscriptionID else {
|
||||||
|
switchLocationChannel(to: context.activeChannel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.startGeoParticipantRefreshTimer()
|
||||||
|
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||||
|
let filter = NostrFilter.geohashEphemeral(
|
||||||
|
channel.geohash,
|
||||||
|
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
|
||||||
|
limit: TransportConfig.nostrGeohashInitialLimit
|
||||||
|
)
|
||||||
|
let subRelays = GeoRelayDirectory.shared.closestRelays(
|
||||||
|
toGeohash: channel.geohash,
|
||||||
|
count: TransportConfig.nostrGeoRelayCount
|
||||||
|
)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.inbound.subscribeNostrEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let dmSub = context.geoDmSubscriptionID {
|
||||||
|
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
||||||
|
context.setGeoDmSubscriptionID(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
|
let dmSub = "geo-dm-\(channel.geohash)"
|
||||||
|
context.setGeoDmSubscriptionID(dmSub)
|
||||||
|
let dmFilter = NostrFilter.giftWrapsFor(
|
||||||
|
pubkey: identity.publicKeyHex,
|
||||||
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
|
)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.inbound.subscribeGiftWrap(giftWrap, id: identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func switchLocationChannel(to channel: ChannelID) {
|
||||||
|
guard let context else { return }
|
||||||
|
context.resetPublicMessagePipeline()
|
||||||
|
context.activeChannel = channel
|
||||||
|
context.updatePublicMessagePipelineChannel(channel)
|
||||||
|
|
||||||
|
context.clearProcessedNostrEvents()
|
||||||
|
switch channel {
|
||||||
|
case .mesh:
|
||||||
|
context.refreshVisibleMessages(from: .mesh)
|
||||||
|
let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count
|
||||||
|
if emptyMesh > 0 {
|
||||||
|
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
|
||||||
|
}
|
||||||
|
context.stopGeoParticipantRefreshTimer()
|
||||||
|
context.setActiveParticipantGeohash(nil)
|
||||||
|
context.clearTeleportedGeo()
|
||||||
|
|
||||||
|
case .location:
|
||||||
|
context.refreshVisibleMessages(from: channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .location = channel {
|
||||||
|
for content in context.drainPendingGeohashSystemMessages() {
|
||||||
|
context.addPublicSystemMessage(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let sub = context.geoSubscriptionID {
|
||||||
|
NostrRelayManager.shared.unsubscribe(id: sub)
|
||||||
|
context.setGeoChatSubscriptionID(nil)
|
||||||
|
}
|
||||||
|
if let dmSub = context.geoDmSubscriptionID {
|
||||||
|
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
||||||
|
context.setGeoDmSubscriptionID(nil)
|
||||||
|
}
|
||||||
|
context.currentGeohash = nil
|
||||||
|
context.setActiveParticipantGeohash(nil)
|
||||||
|
context.clearGeoNicknames()
|
||||||
|
|
||||||
|
guard case .location(let channel) = channel else { return }
|
||||||
|
context.currentGeohash = channel.geohash
|
||||||
|
context.setActiveParticipantGeohash(channel.geohash)
|
||||||
|
|
||||||
|
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
|
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
|
let key = identity.publicKeyHex.lowercased()
|
||||||
|
if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
|
||||||
|
context.markGeoTeleported(key)
|
||||||
|
SecureLogger.info(
|
||||||
|
"GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
context.clearGeoTeleported(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let subID = "geo-\(channel.geohash)"
|
||||||
|
context.setGeoChatSubscriptionID(subID)
|
||||||
|
context.startGeoParticipantRefreshTimer()
|
||||||
|
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
||||||
|
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
|
||||||
|
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.inbound.handleNostrEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribeToGeoChat(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func subscribeToGeoChat(_ channel: GeohashChannel) {
|
||||||
|
guard let context else { return }
|
||||||
|
guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return }
|
||||||
|
|
||||||
|
let dmSub = "geo-dm-\(channel.geohash)"
|
||||||
|
context.setGeoDmSubscriptionID(dmSub)
|
||||||
|
if TorManager.shared.isReady {
|
||||||
|
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
|
||||||
|
}
|
||||||
|
let dmFilter = NostrFilter.giftWrapsFor(
|
||||||
|
pubkey: identity.publicKeyHex,
|
||||||
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
|
)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.inbound.handleGiftWrap(giftWrap, id: identity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) {
|
||||||
|
guard let context else { return }
|
||||||
|
let channel = geoContext.channel
|
||||||
|
let event = geoContext.event
|
||||||
|
let identity = geoContext.identity
|
||||||
|
|
||||||
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||||
|
toGeohash: channel.geohash,
|
||||||
|
count: TransportConfig.nostrGeoRelayCount
|
||||||
|
)
|
||||||
|
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session)
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||||
|
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
|
||||||
|
SecureLogger.debug(
|
||||||
|
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
|
||||||
|
if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
|
||||||
|
let key = identity.publicKeyHex.lowercased()
|
||||||
|
context.markGeoTeleported(key)
|
||||||
|
SecureLogger.info(
|
||||||
|
"GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.recordProcessedNostrEvent(event.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func beginGeohashSampling(for geohashes: [String]) {
|
||||||
|
guard let context else { return }
|
||||||
|
if !TorManager.shared.isForeground() {
|
||||||
|
endGeohashSampling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let desired = Set(geohashes)
|
||||||
|
let current = Set(context.geoSamplingSubs.values)
|
||||||
|
let toAdd = desired.subtracting(current)
|
||||||
|
let toRemove = current.subtracting(desired)
|
||||||
|
|
||||||
|
for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) {
|
||||||
|
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||||
|
context.removeGeoSamplingSub(subID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for gh in toAdd {
|
||||||
|
subscribe(gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func subscribe(_ gh: String) {
|
||||||
|
guard let context else { return }
|
||||||
|
let subID = "geo-sample-\(gh)"
|
||||||
|
context.addGeoSamplingSub(subID, forGeohash: gh)
|
||||||
|
let filter = NostrFilter.geohashEphemeral(
|
||||||
|
gh,
|
||||||
|
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
|
||||||
|
limit: TransportConfig.nostrGeohashSampleLimit
|
||||||
|
)
|
||||||
|
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
|
||||||
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.presence.subscribeNostrEvent(event, gh: gh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func endGeohashSampling() {
|
||||||
|
guard let context else { return }
|
||||||
|
for subID in context.clearGeoSamplingSubs() {
|
||||||
|
NostrRelayManager.shared.unsubscribe(id: subID)
|
||||||
|
}
|
||||||
|
presence.clearGeoSamplingEventDedup()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func setupNostrMessageHandling() {
|
||||||
|
guard let context else { return }
|
||||||
|
guard let currentIdentity = context.currentNostrIdentity() else {
|
||||||
|
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.debug(
|
||||||
|
"🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
|
||||||
|
let filter = NostrFilter.giftWrapsFor(
|
||||||
|
pubkey: currentIdentity.publicKeyHex,
|
||||||
|
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||||
|
)
|
||||||
|
|
||||||
|
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.inbound.handleNostrMessage(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The narrow surface `NostrInboundPipeline` needs from its owner.
|
||||||
|
///
|
||||||
|
/// Split out of `ChatNostrContext`: member names are shared with the sibling
|
||||||
|
/// component contexts so `ChatViewModel` provides a single witness for each.
|
||||||
|
@MainActor
|
||||||
|
protocol NostrInboundPipelineContext: AnyObject {
|
||||||
|
var currentGeohash: String? { get }
|
||||||
|
|
||||||
|
// MARK: Event dedup
|
||||||
|
func hasProcessedNostrEvent(_ eventID: String) -> Bool
|
||||||
|
func recordProcessedNostrEvent(_ eventID: String)
|
||||||
|
|
||||||
|
// MARK: Nostr identity & blocking
|
||||||
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
||||||
|
func currentNostrIdentity() -> NostrIdentity?
|
||||||
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||||
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String
|
||||||
|
|
||||||
|
// MARK: Presence & key mapping
|
||||||
|
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String)
|
||||||
|
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
|
||||||
|
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
|
||||||
|
func recordGeoParticipant(pubkeyHex: String)
|
||||||
|
|
||||||
|
// MARK: Inbound public messages
|
||||||
|
func handlePublicMessage(_ message: BitchatMessage)
|
||||||
|
func checkForMentions(_ message: BitchatMessage)
|
||||||
|
func sendHapticFeedback(for message: BitchatMessage)
|
||||||
|
func parseMentions(from content: String) -> [String]
|
||||||
|
|
||||||
|
// MARK: Inbound private (DM) payloads
|
||||||
|
func handlePrivateMessage(
|
||||||
|
_ payload: NoisePayload,
|
||||||
|
senderPubkey: String,
|
||||||
|
convKey: PeerID,
|
||||||
|
id: NostrIdentity,
|
||||||
|
messageTimestamp: Date
|
||||||
|
)
|
||||||
|
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
|
||||||
|
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ChatViewModel: NostrInboundPipelineContext {
|
||||||
|
// `currentGeohash`, the identity/blocking members, key mapping, and the
|
||||||
|
// inbound message handlers already have witnesses on `ChatViewModel`.
|
||||||
|
// The members below flatten nested service accesses into intent-named calls.
|
||||||
|
|
||||||
|
func hasProcessedNostrEvent(_ eventID: String) -> Bool {
|
||||||
|
deduplicationService.hasProcessedNostrEvent(eventID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordProcessedNostrEvent(_ eventID: String) {
|
||||||
|
deduplicationService.recordNostrEvent(eventID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) {
|
||||||
|
locationPresenceStore.setNickname(nickname, for: pubkeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordGeoParticipant(pubkeyHex: String) {
|
||||||
|
participantTracker.recordParticipant(pubkeyHex: pubkeyHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The inbound Nostr hot path: raw relay events in, chat messages / Noise
|
||||||
|
/// payloads out. Pure transformation plus dedup — no relay lifecycle.
|
||||||
|
///
|
||||||
|
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
||||||
|
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
||||||
|
/// dominate real relay traffic; events are recorded only AFTER verification so
|
||||||
|
/// a forged-signature copy can never poison the dedup set; gift-wrap
|
||||||
|
/// verification for the account mailbox runs off-main with an atomic
|
||||||
|
/// main-actor check-and-record.
|
||||||
|
final class NostrInboundPipeline {
|
||||||
|
private weak var context: (any NostrInboundPipelineContext)?
|
||||||
|
private let presence: GeoPresenceTracker
|
||||||
|
private var geoEventLogCount = 0
|
||||||
|
|
||||||
|
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
||||||
|
self.context = context
|
||||||
|
self.presence = presence
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||||
|
guard let context else { return }
|
||||||
|
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
||||||
|
// duplicates dominate real traffic and must not pay for crypto.
|
||||||
|
// Only verified events are recorded, so a forged-signature copy can
|
||||||
|
// never poison the dedup set and suppress the genuine event.
|
||||||
|
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||||
|
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||||
|
!context.hasProcessedNostrEvent(event.id)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard event.isValidSignature() else { return }
|
||||||
|
|
||||||
|
context.recordProcessedNostrEvent(event.id)
|
||||||
|
|
||||||
|
if let gh = context.currentGeohash,
|
||||||
|
let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh),
|
||||||
|
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
||||||
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
if Date().timeIntervalSince(eventTime) < 15 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||||
|
let nick = nickTag[1].trimmed
|
||||||
|
context.setGeoNickname(nick, forPubkey: event.pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
|
||||||
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
|
||||||
|
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||||
|
|
||||||
|
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if GeoPresenceTracker.hasTeleportTag(event) {
|
||||||
|
let key = event.pubkey.lowercased()
|
||||||
|
let isSelf: Bool = {
|
||||||
|
if let gh = context.currentGeohash,
|
||||||
|
let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) {
|
||||||
|
return myIdentity.publicKeyHex.lowercased() == key
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
if !isSelf {
|
||||||
|
presence.scheduleMarkPeerTeleported(key, logged: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let senderName = context.displayNameForNostrPubkey(event.pubkey)
|
||||||
|
let content = event.content.trimmed
|
||||||
|
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
let timestamp = min(rawTs, Date())
|
||||||
|
let mentions = context.parseMentions(from: content)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: event.id,
|
||||||
|
sender: senderName,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false,
|
||||||
|
senderPeerID: PeerID(nostr: event.pubkey),
|
||||||
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
|
)
|
||||||
|
|
||||||
|
Task { @MainActor [weak context] in
|
||||||
|
guard let context else { return }
|
||||||
|
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
||||||
|
context.handlePublicMessage(message)
|
||||||
|
if !isBlocked {
|
||||||
|
context.checkForMentions(message)
|
||||||
|
context.sendHapticFeedback(for: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func handleNostrEvent(_ event: NostrEvent) {
|
||||||
|
guard let context else { return }
|
||||||
|
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
||||||
|
// duplicates dominate real traffic and must not pay for crypto.
|
||||||
|
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||||
|
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if context.hasProcessedNostrEvent(event.id) { return }
|
||||||
|
guard event.isValidSignature() else { return }
|
||||||
|
context.recordProcessedNostrEvent(event.id)
|
||||||
|
|
||||||
|
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||||
|
geoEventLogCount += 1
|
||||||
|
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||||
|
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasTeleportTag = GeoPresenceTracker.hasTeleportTag(event)
|
||||||
|
|
||||||
|
let isSelf: Bool = {
|
||||||
|
if let gh = context.currentGeohash,
|
||||||
|
let my = try? context.deriveNostrIdentity(forGeohash: gh) {
|
||||||
|
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
if hasTeleportTag, !isSelf {
|
||||||
|
presence.scheduleMarkPeerTeleported(event.pubkey.lowercased(), logged: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||||
|
|
||||||
|
if isSelf {
|
||||||
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
if Date().timeIntervalSince(eventTime) < 15 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||||
|
context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
|
||||||
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
|
||||||
|
|
||||||
|
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let senderName = context.displayNameForNostrPubkey(event.pubkey)
|
||||||
|
let content = event.content
|
||||||
|
|
||||||
|
if let teleTag = event.tags.first(where: { $0.first == "t" }),
|
||||||
|
teleTag.count >= 2,
|
||||||
|
teleTag[1] == "teleport",
|
||||||
|
content.trimmed.isEmpty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
let mentions = context.parseMentions(from: content)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: event.id,
|
||||||
|
sender: senderName,
|
||||||
|
content: content,
|
||||||
|
timestamp: min(rawTs, Date()),
|
||||||
|
isRelay: false,
|
||||||
|
senderPeerID: PeerID(nostr: event.pubkey),
|
||||||
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
|
)
|
||||||
|
|
||||||
|
Task { @MainActor [weak context] in
|
||||||
|
guard let context else { return }
|
||||||
|
context.handlePublicMessage(message)
|
||||||
|
context.checkForMentions(message)
|
||||||
|
context.sendHapticFeedback(for: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
|
guard let context else { return }
|
||||||
|
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||||
|
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||||
|
guard giftWrap.isValidSignature() else { return }
|
||||||
|
context.recordProcessedNostrEvent(giftWrap.id)
|
||||||
|
|
||||||
|
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: id
|
||||||
|
),
|
||||||
|
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||||
|
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
|
let noisePayload = NoisePayload.decode(packet.payload)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||||
|
let convKey = PeerID(nostr_: senderPubkey)
|
||||||
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||||
|
|
||||||
|
switch noisePayload.type {
|
||||||
|
case .privateMessage:
|
||||||
|
context.handlePrivateMessage(
|
||||||
|
noisePayload,
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
convKey: convKey,
|
||||||
|
id: id,
|
||||||
|
messageTimestamp: messageTimestamp
|
||||||
|
)
|
||||||
|
case .delivered:
|
||||||
|
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
|
case .readReceipt:
|
||||||
|
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
|
case .verifyChallenge, .verifyResponse:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||||
|
guard let context else { return }
|
||||||
|
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||||
|
if context.hasProcessedNostrEvent(giftWrap.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard giftWrap.isValidSignature() else { return }
|
||||||
|
context.recordProcessedNostrEvent(giftWrap.id)
|
||||||
|
|
||||||
|
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: id
|
||||||
|
) else {
|
||||||
|
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.debug(
|
||||||
|
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||||
|
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
|
let payload = NoisePayload.decode(packet.payload)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let convKey = PeerID(nostr_: senderPubkey)
|
||||||
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||||
|
|
||||||
|
switch payload.type {
|
||||||
|
case .privateMessage:
|
||||||
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||||
|
context.handlePrivateMessage(
|
||||||
|
payload,
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
convKey: convKey,
|
||||||
|
id: id,
|
||||||
|
messageTimestamp: messageTimestamp
|
||||||
|
)
|
||||||
|
case .delivered:
|
||||||
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
|
case .readReceipt:
|
||||||
|
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
|
case .verifyChallenge, .verifyResponse:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||||
|
guard let context else { return }
|
||||||
|
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
||||||
|
// processNostrMessage, which then does the authoritative
|
||||||
|
// check-and-record. Recording stays after verification so a
|
||||||
|
// forged-signature copy can never poison the dedup set and suppress
|
||||||
|
// the genuine event.
|
||||||
|
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||||
|
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
|
await self?.processNostrMessage(giftWrap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||||
|
guard giftWrap.isValidSignature() else { return }
|
||||||
|
guard let context else { return }
|
||||||
|
// Authoritative check-and-record, atomic on the main actor so two
|
||||||
|
// concurrent detached tasks can't both process the same event.
|
||||||
|
let alreadyProcessed: Bool = await MainActor.run {
|
||||||
|
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
|
||||||
|
context.recordProcessedNostrEvent(giftWrap.id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if alreadyProcessed { return }
|
||||||
|
let currentIdentity: NostrIdentity? = await MainActor.run {
|
||||||
|
context.currentNostrIdentity()
|
||||||
|
}
|
||||||
|
guard let currentIdentity else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
||||||
|
giftWrap: giftWrap,
|
||||||
|
recipientIdentity: currentIdentity
|
||||||
|
)
|
||||||
|
|
||||||
|
if content.hasPrefix("verify:") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if content.hasPrefix("bitchat1:") {
|
||||||
|
let packet: BitchatPacket? = await MainActor.run {
|
||||||
|
Self.decodeEmbeddedBitChatPacket(from: content)
|
||||||
|
}
|
||||||
|
guard let packet else {
|
||||||
|
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let actualSenderNoiseKey: Data? = await MainActor.run {
|
||||||
|
self.findNoiseKey(for: senderPubkey)
|
||||||
|
}
|
||||||
|
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
||||||
|
|
||||||
|
if packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
|
let payload = NoisePayload.decode(packet.payload) {
|
||||||
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||||
|
await MainActor.run {
|
||||||
|
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
||||||
|
|
||||||
|
switch payload.type {
|
||||||
|
case .privateMessage:
|
||||||
|
context.handlePrivateMessage(
|
||||||
|
payload,
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
convKey: targetPeerID,
|
||||||
|
id: currentIdentity,
|
||||||
|
messageTimestamp: messageTimestamp
|
||||||
|
)
|
||||||
|
case .delivered:
|
||||||
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||||
|
case .readReceipt:
|
||||||
|
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||||
|
case .verifyChallenge, .verifyResponse:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
|
||||||
|
/// store. Lives here because the inbound DM path needs it per message;
|
||||||
|
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
|
||||||
|
@MainActor
|
||||||
|
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||||
|
let favorites = FavoritesPersistenceService.shared.favorites.values
|
||||||
|
var npubToMatch = nostrPubkey
|
||||||
|
|
||||||
|
if !nostrPubkey.hasPrefix("npub") {
|
||||||
|
if let pubkeyData = Data(hexString: nostrPubkey),
|
||||||
|
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
|
||||||
|
npubToMatch = encoded
|
||||||
|
} else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for relationship in favorites {
|
||||||
|
if let storedNostrKey = relationship.peerNostrPublicKey {
|
||||||
|
if storedNostrKey == npubToMatch {
|
||||||
|
return relationship.peerNoisePublicKey
|
||||||
|
}
|
||||||
|
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
|
||||||
|
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
|
||||||
|
return relationship.peerNoisePublicKey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.debug(
|
||||||
|
"⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension NostrInboundPipeline {
|
||||||
|
@MainActor
|
||||||
|
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
||||||
|
guard content.hasPrefix("bitchat1:") else { return nil }
|
||||||
|
let encoded = String(content.dropFirst("bitchat1:".count))
|
||||||
|
let maxBytes = FileTransferLimits.maxFramedFileBytes
|
||||||
|
let maxEncoded = ((maxBytes + 2) / 3) * 4
|
||||||
|
guard encoded.count <= maxEncoded else { return nil }
|
||||||
|
guard let packetData = Base64URLCoding.decode(encoded),
|
||||||
|
packetData.count <= maxBytes
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return BitchatPacket.from(packetData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
// ChatNostrCoordinatorContextTests.swift
|
// ChatNostrCoordinatorContextTests.swift
|
||||||
// bitchatTests
|
// bitchatTests
|
||||||
//
|
//
|
||||||
// Exercises `ChatNostrCoordinator` against a mock `ChatNostrContext` —
|
// Exercises the `ChatNostrCoordinator` facade and its components
|
||||||
// proving the coordinator works without a `ChatViewModel`, following the
|
// (`NostrInboundPipeline`, `GeohashSubscriptionManager`, `GeoPresenceTracker`)
|
||||||
// `ChatDeliveryCoordinatorContextTests` / `ChatPrivateConversationCoordinatorContextTests`
|
// against a mock `ChatNostrContext` — proving the stack works without a
|
||||||
// exemplars.
|
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
||||||
|
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
@@ -15,8 +16,9 @@ import BitFoundation
|
|||||||
|
|
||||||
// MARK: - Mock Context
|
// MARK: - Mock Context
|
||||||
|
|
||||||
/// Lightweight stand-in for `ChatNostrContext` proving that
|
/// Lightweight stand-in for `ChatNostrContext` (and, via protocol
|
||||||
/// `ChatNostrCoordinator` is testable without a `ChatViewModel`.
|
/// inheritance, the component contexts) proving that the Nostr stack is
|
||||||
|
/// testable without a `ChatViewModel`.
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class MockChatNostrContext: ChatNostrContext {
|
private final class MockChatNostrContext: ChatNostrContext {
|
||||||
// Channel & subscription state
|
// Channel & subscription state
|
||||||
@@ -250,7 +252,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
)
|
)
|
||||||
context.displayNamesByPubkey[event.pubkey] = "alice#1234"
|
context.displayNamesByPubkey[event.pubkey] = "alice#1234"
|
||||||
|
|
||||||
coordinator.handleNostrEvent(event)
|
coordinator.inbound.handleNostrEvent(event)
|
||||||
await drainMainQueue()
|
await drainMainQueue()
|
||||||
|
|
||||||
// Dedup recorded exactly once, presence and key mapping updated.
|
// Dedup recorded exactly once, presence and key mapping updated.
|
||||||
@@ -268,7 +270,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
#expect(context.hapticMessageIDs == [event.id])
|
#expect(context.hapticMessageIDs == [event.id])
|
||||||
|
|
||||||
// A replay of the same event is dropped before any processing.
|
// A replay of the same event is dropped before any processing.
|
||||||
coordinator.handleNostrEvent(event)
|
coordinator.inbound.handleNostrEvent(event)
|
||||||
await drainMainQueue()
|
await drainMainQueue()
|
||||||
#expect(context.recordedNostrEventIDs == [event.id])
|
#expect(context.recordedNostrEventIDs == [event.id])
|
||||||
#expect(context.handledPublicMessages.count == 1)
|
#expect(context.handledPublicMessages.count == 1)
|
||||||
@@ -288,7 +290,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
teleported: true
|
teleported: true
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator.handleNostrEvent(event)
|
coordinator.inbound.handleNostrEvent(event)
|
||||||
await drainMainQueue()
|
await drainMainQueue()
|
||||||
|
|
||||||
// Teleport detection fires even though the empty message is dropped.
|
// Teleport detection fires even though the empty message is dropped.
|
||||||
@@ -310,7 +312,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
)
|
)
|
||||||
context.blockedNostrPubkeys.insert(event.pubkey.lowercased())
|
context.blockedNostrPubkeys.insert(event.pubkey.lowercased())
|
||||||
|
|
||||||
coordinator.handleNostrEvent(event)
|
coordinator.inbound.handleNostrEvent(event)
|
||||||
await drainMainQueue()
|
await drainMainQueue()
|
||||||
|
|
||||||
// The event is still recorded for dedup but nothing else happens.
|
// The event is still recorded for dedup but nothing else happens.
|
||||||
@@ -337,7 +339,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
senderIdentity: sender
|
senderIdentity: sender
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator.handleGiftWrap(giftWrap, id: recipient)
|
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||||
|
|
||||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||||
@@ -354,7 +356,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
#expect(pm.content == "psst")
|
#expect(pm.content == "psst")
|
||||||
|
|
||||||
// The same gift wrap is dropped on replay.
|
// The same gift wrap is dropped on replay.
|
||||||
coordinator.handleGiftWrap(giftWrap, id: recipient)
|
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||||
#expect(context.handledPrivateMessages.count == 1)
|
#expect(context.handledPrivateMessages.count == 1)
|
||||||
}
|
}
|
||||||
@@ -367,7 +369,7 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
context.currentGeohash = "u4pruyd"
|
context.currentGeohash = "u4pruyd"
|
||||||
context.geoNicknames = ["abcd": "alice"]
|
context.geoNicknames = ["abcd": "alice"]
|
||||||
|
|
||||||
coordinator.switchLocationChannel(to: .mesh)
|
coordinator.subscriptions.switchLocationChannel(to: .mesh)
|
||||||
|
|
||||||
#expect(context.pipelineResetCount == 1)
|
#expect(context.pipelineResetCount == 1)
|
||||||
#expect(context.activeChannel == .mesh)
|
#expect(context.activeChannel == .mesh)
|
||||||
@@ -451,3 +453,97 @@ struct ChatNostrCoordinatorContextTests {
|
|||||||
#expect(coordinator.nostrPubkeyForDisplayName("nobody") == nil)
|
#expect(coordinator.nostrPubkeyForDisplayName("nobody") == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - GeoPresenceTracker Tests
|
||||||
|
|
||||||
|
/// Focused tests for seams the coordinator split made independently
|
||||||
|
/// testable: the sampling-event LRU dedup and the per-geohash notification
|
||||||
|
/// cooldown. The cooldown tests stop short of the live notification center by
|
||||||
|
/// pre-seeding the timeline append as a duplicate.
|
||||||
|
struct GeoPresenceTrackerTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func samplingEventDedup_evictsOldestBeyondLRUCap() {
|
||||||
|
let context = MockChatNostrContext()
|
||||||
|
let tracker = GeoPresenceTracker(context: context)
|
||||||
|
let cap = TransportConfig.geoSamplingEventLRUCap
|
||||||
|
|
||||||
|
// Empty IDs are never deduplicated.
|
||||||
|
#expect(tracker.shouldProcessGeoSamplingEvent(""))
|
||||||
|
#expect(tracker.shouldProcessGeoSamplingEvent(""))
|
||||||
|
|
||||||
|
// First sight passes; a replay is rejected.
|
||||||
|
#expect(tracker.shouldProcessGeoSamplingEvent("ev-0"))
|
||||||
|
#expect(!tracker.shouldProcessGeoSamplingEvent("ev-0"))
|
||||||
|
|
||||||
|
// Fill one past the cap: the oldest entry is evicted and accepted
|
||||||
|
// again, while a still-resident entry stays deduplicated.
|
||||||
|
for i in 1...cap {
|
||||||
|
#expect(tracker.shouldProcessGeoSamplingEvent("ev-\(i)"))
|
||||||
|
}
|
||||||
|
#expect(tracker.shouldProcessGeoSamplingEvent("ev-0"))
|
||||||
|
#expect(!tracker.shouldProcessGeoSamplingEvent("ev-\(cap)"))
|
||||||
|
|
||||||
|
// Clearing resets the dedup entirely.
|
||||||
|
tracker.clearGeoSamplingEventDedup()
|
||||||
|
#expect(tracker.shouldProcessGeoSamplingEvent("ev-\(cap)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func notificationCooldown_skipsWithinWindow() async throws {
|
||||||
|
let context = MockChatNostrContext()
|
||||||
|
let tracker = GeoPresenceTracker(context: context)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
|
content: "sampled activity",
|
||||||
|
geohash: "9q8yy",
|
||||||
|
senderIdentity: sender,
|
||||||
|
nickname: "alice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Within the cooldown window nothing is appended or re-stamped.
|
||||||
|
let recent = Date()
|
||||||
|
context.lastGeoNotificationAt["9q8yy"] = recent
|
||||||
|
tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event)
|
||||||
|
await drainMainQueue()
|
||||||
|
#expect(context.appendedGeohashMessages.isEmpty)
|
||||||
|
#expect(context.lastGeoNotificationAt["9q8yy"] == recent)
|
||||||
|
#expect(context.synchronizedGeohashes.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func notificationCooldown_stampsGeohashOnceWindowElapses() async throws {
|
||||||
|
let context = MockChatNostrContext()
|
||||||
|
let tracker = GeoPresenceTracker(context: context)
|
||||||
|
let sender = try NostrIdentity.generate()
|
||||||
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
|
content: "sampled activity",
|
||||||
|
geohash: "9q8yy",
|
||||||
|
senderIdentity: sender,
|
||||||
|
nickname: "alice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-seed the same event ID so the timeline append reports a
|
||||||
|
// duplicate and the flow never reaches the live notification center.
|
||||||
|
let placeholder = BitchatMessage(
|
||||||
|
id: event.id,
|
||||||
|
sender: "seed",
|
||||||
|
content: "seed",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
#expect(context.appendGeohashMessageIfAbsent(placeholder, toGeohash: "9q8yy"))
|
||||||
|
|
||||||
|
// Cooldown elapsed: the geohash is re-stamped and the append is
|
||||||
|
// attempted (and rejected as a duplicate, so no store sync either).
|
||||||
|
let stale = Date().addingTimeInterval(-TransportConfig.uiGeoNotifyCooldownSeconds - 1)
|
||||||
|
context.lastGeoNotificationAt["9q8yy"] = stale
|
||||||
|
tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event)
|
||||||
|
await drainMainQueue()
|
||||||
|
|
||||||
|
let stamped = try #require(context.lastGeoNotificationAt["9q8yy"])
|
||||||
|
#expect(stamped > stale)
|
||||||
|
#expect(context.appendedGeohashMessages.count == 1)
|
||||||
|
#expect(context.synchronizedGeohashes.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - 1a. Nostr inbound event handling (fresh events)
|
// MARK: - 1a. Nostr inbound event handling (fresh events)
|
||||||
|
|
||||||
/// `ChatNostrCoordinator.handleNostrEvent` for never-seen geo events
|
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
|
||||||
/// (kind 20000): signature verification, dedup record, presence/nickname
|
/// (kind 20000): signature verification, dedup record, presence/nickname
|
||||||
/// bookkeeping, and public-message ingest scheduling.
|
/// bookkeeping, and public-message ingest scheduling.
|
||||||
func testNostrInboundEventHandling_freshEvents() throws {
|
func testNostrInboundEventHandling_freshEvents() throws {
|
||||||
@@ -68,7 +68,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
keepAlive.append((context, coordinator))
|
keepAlive.append((context, coordinator))
|
||||||
let start = Date()
|
let start = Date()
|
||||||
for event in events {
|
for event in events {
|
||||||
coordinator.handleNostrEvent(event)
|
coordinator.inbound.handleNostrEvent(event)
|
||||||
}
|
}
|
||||||
samples.append(Date().timeIntervalSince(start))
|
samples.append(Date().timeIntervalSince(start))
|
||||||
XCTAssertEqual(context.processedEventCount, events.count)
|
XCTAssertEqual(context.processedEventCount, events.count)
|
||||||
@@ -89,7 +89,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
let coordinator = ChatNostrCoordinator(context: context)
|
let coordinator = ChatNostrCoordinator(context: context)
|
||||||
// Pre-warm: every event is now recorded as processed.
|
// Pre-warm: every event is now recorded as processed.
|
||||||
for event in events {
|
for event in events {
|
||||||
coordinator.handleNostrEvent(event)
|
coordinator.inbound.handleNostrEvent(event)
|
||||||
}
|
}
|
||||||
XCTAssertEqual(context.processedEventCount, events.count)
|
XCTAssertEqual(context.processedEventCount, events.count)
|
||||||
var samples: [TimeInterval] = []
|
var samples: [TimeInterval] = []
|
||||||
@@ -97,7 +97,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
measure {
|
measure {
|
||||||
let start = Date()
|
let start = Date()
|
||||||
for event in events {
|
for event in events {
|
||||||
coordinator.handleNostrEvent(event)
|
coordinator.inbound.handleNostrEvent(event)
|
||||||
}
|
}
|
||||||
samples.append(Date().timeIntervalSince(start))
|
samples.append(Date().timeIntervalSince(start))
|
||||||
}
|
}
|
||||||
@@ -370,8 +370,9 @@ private struct SeededGenerator: RandomNumberGenerator {
|
|||||||
|
|
||||||
// MARK: - Mock ChatNostrContext
|
// MARK: - Mock ChatNostrContext
|
||||||
|
|
||||||
/// Minimal `ChatNostrContext` for benchmarking `ChatNostrCoordinator`
|
/// Minimal `ChatNostrContext` for benchmarking the `ChatNostrCoordinator`
|
||||||
/// without a `ChatViewModel` (mirrors `ChatNostrCoordinatorContextTests`).
|
/// stack (`NostrInboundPipeline` in particular) without a `ChatViewModel`
|
||||||
|
/// (mirrors `ChatNostrCoordinatorContextTests`).
|
||||||
/// Callbacks are cheap dictionary/array operations so the measured cost is
|
/// Callbacks are cheap dictionary/array operations so the measured cost is
|
||||||
/// the coordinator's own pipeline.
|
/// the coordinator's own pipeline.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
Reference in New Issue
Block a user