mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 05:05:19 +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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user