diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 2a9e889d..97c13fbf 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -18,6 +18,7 @@ struct NostrProtocol { case seal = 13 // NIP-17 sealed event case giftWrap = 1059 // NIP-59 gift wrap case ephemeralEvent = 20000 + case geohashPresence = 20001 } /// Create a NIP-17 private message @@ -125,6 +126,24 @@ struct NostrProtocol { return try event.sign(with: schnorrKey) } + /// Create a geohash presence heartbeat (kind 20001) + /// Must contain empty content and NO nickname tag + static func createGeohashPresenceEvent( + geohash: String, + senderIdentity: NostrIdentity + ) throws -> NostrEvent { + let tags = [["g", geohash]] + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .geohashPresence, + tags: tags, + content: "" + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } + /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. static func createGeohashTextNote( content: String, diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 722d8a77..1211c2ec 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -887,10 +887,10 @@ struct NostrFilter: Encodable { return filter } - // For location channels: geohash-scoped ephemeral events (kind 20000) + // For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001) static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter { var filter = NostrFilter() - filter.kinds = [20000] + filter.kinds = [20000, 20001] filter.since = since?.timeIntervalSince1970.toInt() filter.tagFilters = ["g": [geohash]] filter.limit = limit diff --git a/bitchat/Services/GeohashPresenceService.swift b/bitchat/Services/GeohashPresenceService.swift new file mode 100644 index 00000000..08415631 --- /dev/null +++ b/bitchat/Services/GeohashPresenceService.swift @@ -0,0 +1,171 @@ +// +// GeohashPresenceService.swift +// bitchat +// +// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001) +// to geohash location channels. +// +// This is free and unencumbered software released into the public domain. +// + +import Foundation +import Combine +import BitLogger +import Tor + +/// Service that coordinates the broadcasting of presence heartbeats. +/// +/// Behavior: +/// - Monitors location changes via LocationStateManager +/// - Broadcasts Kind 20001 events to low-precision geohash channels +/// - Uses randomized timing (40-80s loop) and decorrelated bursts +/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels +@MainActor +final class GeohashPresenceService: ObservableObject { + static let shared = GeohashPresenceService() + + private var subscriptions = Set() + private var heartbeatTimer: Timer? + private let idBridge = NostrIdentityBridge() + + // MARK: - Constants + + // Loop interval range in seconds + private let loopMinInterval: TimeInterval = 40.0 + private let loopMaxInterval: TimeInterval = 80.0 + + // Per-broadcast decorrelation delay range in seconds + private let burstMinDelay: TimeInterval = 2.0 + private let burstMaxDelay: TimeInterval = 5.0 + + // Privacy: Only broadcast to these levels + private let allowedPrecisions: Set = [ + GeohashChannelLevel.region.precision, // 2 + GeohashChannelLevel.province.precision, // 4 + GeohashChannelLevel.city.precision // 5 + ] + + private init() { + setupObservers() + } + + /// Start the service (safe to call multiple times) + func start() { + SecureLogger.info("Presence: service starting...", category: .session) + scheduleNextHeartbeat() + } + + private func setupObservers() { + // Monitor location channel changes + LocationStateManager.shared.$availableChannels + .dropFirst() + .sink { [weak self] _ in + self?.handleLocationChange() + } + .store(in: &subscriptions) + + // Monitor Tor readiness to kick off heartbeat if it was stalled + NotificationCenter.default.publisher(for: .TorDidBecomeReady) + .sink { [weak self] _ in + self?.handleConnectivityChange() + } + .store(in: &subscriptions) + } + + private func handleLocationChange() { + // When location changes, we trigger an immediate (but slightly delayed) heartbeat + // to announce presence in the new zone, then reset the loop. + SecureLogger.debug("Presence: location changed, scheduling update", category: .session) + heartbeatTimer?.invalidate() + + // Small delay to allow location state to settle + heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + self?.performHeartbeat() + } + } + } + + private func handleConnectivityChange() { + SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session) + // If we were waiting for network, do it now + if heartbeatTimer == nil || !heartbeatTimer!.isValid { + scheduleNextHeartbeat() + } + } + + private func scheduleNextHeartbeat() { + heartbeatTimer?.invalidate() + let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval) + heartbeatTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + self?.performHeartbeat() + } + } + } + + private func performHeartbeat() { + // Always schedule next loop first ensures continuity even if this one fails/skips + defer { scheduleNextHeartbeat() } + + // 1. Check preconditions + guard TorManager.shared.isReady else { + SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session) + return + } + + // App must be active (or at least we shouldn't broadcast if in background, usually) + if !TorManager.shared.isForeground() { + return + } + + // 2. Get channels + let channels = LocationStateManager.shared.availableChannels + guard !channels.isEmpty else { return } + + // 3. Filter and broadcast + // We use Task + sleep for decorrelation to allow the main runloop to proceed + for channel in channels { + // Check privacy restriction + if !self.allowedPrecisions.contains(channel.geohash.count) { + continue + } + + // Launch independent task for each channel's delay + Task { @MainActor in + // Random delay for decorrelation + let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay) + let nanoseconds = UInt64(delay * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + + self.broadcastPresence(for: channel.geohash) + } + } + } + + private func broadcastPresence(for geohash: String) { + do { + guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else { + return + } + + let event = try NostrProtocol.createGeohashPresenceEvent( + geohash: geohash, + senderIdentity: identity + ) + + // Send via RelayManager + let targetRelays = GeoRelayDirectory.shared.closestRelays( + toGeohash: geohash, + count: TransportConfig.nostrGeoRelayCount + ) + + if !targetRelays.isEmpty { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session) + } + } catch { + SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session) + } + } +} diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index 28dca5ec..42dbb35b 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -56,7 +56,8 @@ extension ChatViewModel { } func subscribeNostrEvent(_ event: NostrEvent) { - guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue, + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || + event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), !deduplicationService.hasProcessedNostrEvent(event.id) else { return @@ -86,6 +87,11 @@ extension ChatViewModel { // Update participants last-seen for this pubkey participantTracker.recordParticipant(pubkeyHex: event.pubkey) + // If presence heartbeat (Kind 20001), stop here - no content to display + if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { + return + } + // Track teleported tag (only our format ["t","teleport"]) for icon state let hasTeleportTag = event.tags.contains(where: { tag in tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" @@ -239,8 +245,9 @@ extension ChatViewModel { } func handleNostrEvent(_ event: NostrEvent) { - // Only handle ephemeral kind 20000 with matching tag - guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + // Only handle ephemeral kind 20000 or presence kind 20001 with matching tag + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || + event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } // Deduplicate if deduplicationService.hasProcessedNostrEvent(event.id) { return } @@ -299,6 +306,11 @@ extension ChatViewModel { // Update participants last-seen for this pubkey participantTracker.recordParticipant(pubkeyHex: event.pubkey) + // If presence heartbeat (Kind 20001), stop here - no content to display + if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { + return + } + let senderName = displayNameForNostrPubkey(event.pubkey) let content = event.content @@ -464,7 +476,8 @@ extension ChatViewModel { } func subscribeNostrEvent(_ event: NostrEvent, gh: String) { - guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || + event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } // Compute current participant count (5-minute window) BEFORE updating with this event let existingCount = participantTracker.participantCount(for: gh) diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 25c8967d..01842d78 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -40,10 +40,31 @@ struct LocationChannelsSheet: View { } static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String { + // High-precision uncertainty: if count is 0 for high-precision levels, + // show "?" because presence broadcasting is disabled for privacy. + let isHighPrecision = (level == .neighborhood || level == .block || level == .building) + if isHighPrecision && count == 0 { + return String( + format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"), + locale: .current, + level.displayName + ) + } return rowTitle(label: level.displayName, count: count) } static func bookmarkTitle(geohash: String, count: Int) -> String { + // Check precision for bookmarks too + let len = geohash.count + // Neighborhood=6, Block=7, Building=8+ + let isHighPrecision = (len >= 6) + if isHighPrecision && count == 0 { + return String( + format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"), + locale: .current, + "#\(geohash)" + ) + } return rowTitle(label: "#\(geohash)", count: count) } diff --git a/docs/GeohashPresenceSpec.md b/docs/GeohashPresenceSpec.md new file mode 100644 index 00000000..321a491f --- /dev/null +++ b/docs/GeohashPresenceSpec.md @@ -0,0 +1,96 @@ +# Geohash Presence Specification + +## Overview + +The Geohash Presence feature provides a mechanism to track online participants in geohash-based location channels. It uses a dedicated ephemeral Nostr event kind to broadcast "heartbeats," ensuring accurate and privacy-preserving online counts. + +## Nostr Protocol + +### Event Kind +A new ephemeral event kind is defined for presence heartbeats: +- **Kind:** `20001` (`GEOHASH_PRESENCE`) +- **Type:** Ephemeral (not stored by relays long-term) + +### Event Structure +The presence event mimics the structure of a geohash chat message (Kind 20000) but without content or nickname metadata, to minimize overhead and focus purely on "liveness". + +```json +{ + "kind": 20001, + "created_at": , + "tags": [ + ["g", ""] + ], + "content": "", + "pubkey": "", + "id": "", + "sig": "" +} +``` + +* **`content`**: Must be empty string. +* **`tags`**: Must include `["g", ""]`. Should NOT include `["n", ""]`. +* **`pubkey`**: The ephemeral identity derived specifically for this geohash (same as used for chat messages). + +## Client Behavior + +### 1. Broadcasting Presence + +Clients MUST broadcast a Kind 20001 presence event globally when the app is open, regardless of which screen the user is viewing. + +* **Global Heartbeat:** + * **Trigger:** Application start / initialization, or whenever location (available geohashes) changes. + * **Frequency:** Randomized loop interval between **40s and 80s** (average 60s). + * **Scope:** Sent to *all* geohash channels corresponding to the device's *current physical location*. + * **Privacy Restriction:** Presence MUST ONLY be broadcast to low-precision geohash levels to protect user privacy. Specifically: + * **Allowed:** `REGION` (precision 2), `PROVINCE` (precision 4), `CITY` (precision 5). + * **Denied:** `NEIGHBORHOOD` (precision 6), `BLOCK` (precision 7), `BUILDING` (precision 8+). + * **Decorrelation:** Individual broadcasts within a heartbeat loop must be separated by random delays (e.g., 2-5 seconds) to prevent temporal correlation of public keys across different geohash levels. The main loop delay is adjusted to maintain the target average cadence. + +### 2. Subscribing to Presence + +Clients must update their Nostr filters to listen for both chat and presence events on geohash channels. + +* **Filter:** + * `kinds`: `[20000, 20001]` + * `#g`: `[""]` + +### 3. Participant Counting + +The "online participants" count shown in the UI aggregates unique public keys from both presence heartbeats and active chat messages. + +* **Logic:** + * Maintain a map of `pubkey -> last_seen_timestamp` for each geohash. + * Update `last_seen_timestamp` upon receiving a valid **Kind 20001 (Presence)** OR **Kind 20000 (Chat)** event. + * A participant is considered "online" if their `last_seen_timestamp` is within the last **5 minutes**. + +### 4. UI Presentation + +The presentation of the participant count depends on the geohash precision level and data availability. + +* **Standard Display:** For channels where presence is broadcast (Region, Province, City) OR any channel where at least one participant has been detected, show the exact count: `[N people]`. +* **High-Precision Uncertainty:** For high-precision channels (Neighborhood, Block, Building) where: + * Presence broadcasting is disabled (privacy restriction). + * **AND** the detected participant count is `0`. + * **Display:** `[? people]` + * **Reasoning:** Since clients don't announce themselves in these channels, a count of "0" is misleading (people could be lurking). + +### 5. Implementation Details (Android Reference) + +* **`NostrKind.GEOHASH_PRESENCE`**: Added constant `20001`. +* **`NostrProtocol.createGeohashPresenceEvent`**: Helper to generate the event. +* **`GeohashViewModel`**: + * `startGlobalPresenceHeartbeat()`: Coroutine that `collectLatest` on `LocationChannelManager.availableChannels`. + * Implements randomized loop logic (40-80s) and per-broadcast random delays (2-5s). + * Filters channels by `precision <= 5` before broadcasting. +* **`GeohashMessageHandler`**: + * Refactored `onEvent` to update participant counts for both Kind 20000 and 20001. +* **`LocationChannelsSheet`**: + * Implements the `[? people]` display logic for high-precision, zero-count channels. + +## Benefits + +* **Accuracy:** Counts reflect both active listeners (via heartbeats) and active speakers (via messages). +* **Privacy:** High-precision location presence is NOT broadcast. Temporal correlation between different levels is obfuscated via random delays. +* **Consistency:** "Online" status is maintained globally while the app is open. +* **Transparency:** The UI correctly reflects uncertainty (`?`) when privacy rules prevent accurate passive counting.