mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +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
|
||||
func resubscribeCurrentGeohash() {
|
||||
nostrCoordinator.resubscribeCurrentGeohash()
|
||||
nostrCoordinator.subscriptions.resubscribeCurrentGeohash()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
nostrCoordinator.subscribeNostrEvent(event)
|
||||
nostrCoordinator.inbound.subscribeNostrEvent(event)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
nostrCoordinator.subscribeGiftWrap(giftWrap, id: id)
|
||||
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func switchLocationChannel(to channel: ChannelID) {
|
||||
nostrCoordinator.switchLocationChannel(to: channel)
|
||||
nostrCoordinator.subscriptions.switchLocationChannel(to: channel)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
nostrCoordinator.handleNostrEvent(event)
|
||||
nostrCoordinator.inbound.handleNostrEvent(event)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeToGeoChat(_ ch: GeohashChannel) {
|
||||
nostrCoordinator.subscribeToGeoChat(ch)
|
||||
nostrCoordinator.subscriptions.subscribeToGeoChat(ch)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
nostrCoordinator.handleGiftWrap(giftWrap, id: id)
|
||||
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendGeohash(context: GeoOutgoingContext) {
|
||||
nostrCoordinator.sendGeohash(context: context)
|
||||
nostrCoordinator.subscriptions.sendGeohash(context: context)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func beginGeohashSampling(for geohashes: [String]) {
|
||||
nostrCoordinator.beginGeohashSampling(for: geohashes)
|
||||
nostrCoordinator.subscriptions.beginGeohashSampling(for: geohashes)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribe(_ gh: String) {
|
||||
nostrCoordinator.subscribe(gh)
|
||||
nostrCoordinator.subscriptions.subscribe(gh)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||
nostrCoordinator.subscribeNostrEvent(event, gh: gh)
|
||||
nostrCoordinator.presence.subscribeNostrEvent(event, gh: gh)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
|
||||
nostrCoordinator.cooldownPerGeohash(gh, content: content, event: event)
|
||||
nostrCoordinator.presence.cooldownPerGeohash(gh, content: content, event: event)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func endGeohashSampling() {
|
||||
nostrCoordinator.endGeohashSampling()
|
||||
nostrCoordinator.subscriptions.endGeohashSampling()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setupNostrMessageHandling() {
|
||||
nostrCoordinator.setupNostrMessageHandling()
|
||||
nostrCoordinator.subscriptions.setupNostrMessageHandling()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
nostrCoordinator.handleNostrMessage(giftWrap)
|
||||
nostrCoordinator.inbound.handleNostrMessage(giftWrap)
|
||||
}
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
await nostrCoordinator.processNostrMessage(giftWrap)
|
||||
await nostrCoordinator.inbound.processNostrMessage(giftWrap)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||
nostrCoordinator.findNoiseKey(for: nostrPubkey)
|
||||
nostrCoordinator.inbound.findNoiseKey(for: nostrPubkey)
|
||||
}
|
||||
|
||||
@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
|
||||
// bitchatTests
|
||||
//
|
||||
// Exercises `ChatNostrCoordinator` against a mock `ChatNostrContext` —
|
||||
// proving the coordinator works without a `ChatViewModel`, following the
|
||||
// `ChatDeliveryCoordinatorContextTests` / `ChatPrivateConversationCoordinatorContextTests`
|
||||
// exemplars.
|
||||
// Exercises the `ChatNostrCoordinator` facade and its components
|
||||
// (`NostrInboundPipeline`, `GeohashSubscriptionManager`, `GeoPresenceTracker`)
|
||||
// against a mock `ChatNostrContext` — proving the stack works without a
|
||||
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@@ -15,8 +16,9 @@ import BitFoundation
|
||||
|
||||
// MARK: - Mock Context
|
||||
|
||||
/// Lightweight stand-in for `ChatNostrContext` proving that
|
||||
/// `ChatNostrCoordinator` is testable without a `ChatViewModel`.
|
||||
/// Lightweight stand-in for `ChatNostrContext` (and, via protocol
|
||||
/// inheritance, the component contexts) proving that the Nostr stack is
|
||||
/// testable without a `ChatViewModel`.
|
||||
@MainActor
|
||||
private final class MockChatNostrContext: ChatNostrContext {
|
||||
// Channel & subscription state
|
||||
@@ -250,7 +252,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
)
|
||||
context.displayNamesByPubkey[event.pubkey] = "alice#1234"
|
||||
|
||||
coordinator.handleNostrEvent(event)
|
||||
coordinator.inbound.handleNostrEvent(event)
|
||||
await drainMainQueue()
|
||||
|
||||
// Dedup recorded exactly once, presence and key mapping updated.
|
||||
@@ -268,7 +270,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
#expect(context.hapticMessageIDs == [event.id])
|
||||
|
||||
// A replay of the same event is dropped before any processing.
|
||||
coordinator.handleNostrEvent(event)
|
||||
coordinator.inbound.handleNostrEvent(event)
|
||||
await drainMainQueue()
|
||||
#expect(context.recordedNostrEventIDs == [event.id])
|
||||
#expect(context.handledPublicMessages.count == 1)
|
||||
@@ -288,7 +290,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
teleported: true
|
||||
)
|
||||
|
||||
coordinator.handleNostrEvent(event)
|
||||
coordinator.inbound.handleNostrEvent(event)
|
||||
await drainMainQueue()
|
||||
|
||||
// Teleport detection fires even though the empty message is dropped.
|
||||
@@ -310,7 +312,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
)
|
||||
context.blockedNostrPubkeys.insert(event.pubkey.lowercased())
|
||||
|
||||
coordinator.handleNostrEvent(event)
|
||||
coordinator.inbound.handleNostrEvent(event)
|
||||
await drainMainQueue()
|
||||
|
||||
// The event is still recorded for dedup but nothing else happens.
|
||||
@@ -337,7 +339,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
coordinator.handleGiftWrap(giftWrap, id: recipient)
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
@@ -354,7 +356,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
#expect(pm.content == "psst")
|
||||
|
||||
// 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.handledPrivateMessages.count == 1)
|
||||
}
|
||||
@@ -367,7 +369,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
context.currentGeohash = "u4pruyd"
|
||||
context.geoNicknames = ["abcd": "alice"]
|
||||
|
||||
coordinator.switchLocationChannel(to: .mesh)
|
||||
coordinator.subscriptions.switchLocationChannel(to: .mesh)
|
||||
|
||||
#expect(context.pipelineResetCount == 1)
|
||||
#expect(context.activeChannel == .mesh)
|
||||
@@ -451,3 +453,97 @@ struct ChatNostrCoordinatorContextTests {
|
||||
#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)
|
||||
|
||||
/// `ChatNostrCoordinator.handleNostrEvent` for never-seen geo events
|
||||
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
|
||||
/// (kind 20000): signature verification, dedup record, presence/nickname
|
||||
/// bookkeeping, and public-message ingest scheduling.
|
||||
func testNostrInboundEventHandling_freshEvents() throws {
|
||||
@@ -68,7 +68,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
keepAlive.append((context, coordinator))
|
||||
let start = Date()
|
||||
for event in events {
|
||||
coordinator.handleNostrEvent(event)
|
||||
coordinator.inbound.handleNostrEvent(event)
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
XCTAssertEqual(context.processedEventCount, events.count)
|
||||
@@ -89,7 +89,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
// Pre-warm: every event is now recorded as processed.
|
||||
for event in events {
|
||||
coordinator.handleNostrEvent(event)
|
||||
coordinator.inbound.handleNostrEvent(event)
|
||||
}
|
||||
XCTAssertEqual(context.processedEventCount, events.count)
|
||||
var samples: [TimeInterval] = []
|
||||
@@ -97,7 +97,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
measure {
|
||||
let start = Date()
|
||||
for event in events {
|
||||
coordinator.handleNostrEvent(event)
|
||||
coordinator.inbound.handleNostrEvent(event)
|
||||
}
|
||||
samples.append(Date().timeIntervalSince(start))
|
||||
}
|
||||
@@ -370,8 +370,9 @@ private struct SeededGenerator: RandomNumberGenerator {
|
||||
|
||||
// MARK: - Mock ChatNostrContext
|
||||
|
||||
/// Minimal `ChatNostrContext` for benchmarking `ChatNostrCoordinator`
|
||||
/// without a `ChatViewModel` (mirrors `ChatNostrCoordinatorContextTests`).
|
||||
/// Minimal `ChatNostrContext` for benchmarking the `ChatNostrCoordinator`
|
||||
/// stack (`NostrInboundPipeline` in particular) without a `ChatViewModel`
|
||||
/// (mirrors `ChatNostrCoordinatorContextTests`).
|
||||
/// Callbacks are cheap dictionary/array operations so the measured cost is
|
||||
/// the coordinator's own pipeline.
|
||||
@MainActor
|
||||
|
||||
Reference in New Issue
Block a user