From 0f5299a0f5003c555115177031b0547c5a0ce0ff Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:12:49 +0700 Subject: [PATCH 1/7] announce and read presence --- bitchat/Nostr/NostrProtocol.swift | 19 ++ bitchat/Nostr/NostrRelayManager.swift | 4 +- bitchat/Services/GeohashPresenceService.swift | 171 ++++++++++++++++++ .../Extensions/ChatViewModel+Nostr.swift | 21 ++- bitchat/Views/LocationChannelsSheet.swift | 21 +++ docs/GeohashPresenceSpec.md | 96 ++++++++++ 6 files changed, 326 insertions(+), 6 deletions(-) create mode 100644 bitchat/Services/GeohashPresenceService.swift create mode 100644 docs/GeohashPresenceSpec.md 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. From 6630f5a79232084bf8356085a92b2df07bebc578 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:13:24 +0700 Subject: [PATCH 2/7] start the service --- bitchat/BitchatApp.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bitchat/BitchatApp.swift b/bitchat/BitchatApp.swift index 644554b0..049ae56b 100644 --- a/bitchat/BitchatApp.swift +++ b/bitchat/BitchatApp.swift @@ -63,6 +63,10 @@ struct BitchatApp: App { // Initialize network activation policy; will start Tor/Nostr only when allowed NetworkActivationService.shared.start() + + // Start presence service (will wait for Tor readiness) + GeohashPresenceService.shared.start() + // Check for shared content checkForSharedContent() } From b6d8a5b758abfa45be417e7fbf3da3359f191430 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:14:34 +0700 Subject: [PATCH 3/7] update UI on new data --- bitchat/Services/GeohashParticipantTracker.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bitchat/Services/GeohashParticipantTracker.swift b/bitchat/Services/GeohashParticipantTracker.swift index 1bd2df02..0e46ad34 100644 --- a/bitchat/Services/GeohashParticipantTracker.swift +++ b/bitchat/Services/GeohashParticipantTracker.swift @@ -83,6 +83,9 @@ public final class GeohashParticipantTracker: ObservableObject { var map = participants[geohash] ?? [:] map[key] = Date() participants[geohash] = map + + // Always notify observers that state has changed so counts in UI update + objectWillChange.send() // Only refresh visible list if this geohash is currently active if activeGeohash == geohash { From ce31d85323ca5eacf7b5d397e7ba867dc232612c Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:33:21 +0700 Subject: [PATCH 4/7] record participant count before self-suppression --- bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index 42dbb35b..4159c089 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -280,6 +280,9 @@ extension ChatViewModel { } } + // Update participants last-seen for this pubkey + participantTracker.recordParticipant(pubkeyHex: event.pubkey) + // Skip only very recent self-echo from relay; include older self events for hydration if isSelf { let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) @@ -303,9 +306,6 @@ extension ChatViewModel { nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey - // 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 From e02f4327c0a5982a0f5c8c7db73ddc3636f6cbef Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:57:25 +0700 Subject: [PATCH 5/7] move block up --- .../ViewModels/Extensions/ChatViewModel+Nostr.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index 4159c089..9dbb8d38 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -257,6 +257,11 @@ extension ChatViewModel { let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session) + // If this pubkey is blocked, skip mapping, participants, and timeline + if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { + return + } + // Track teleport tag for participants – only our format ["t", "teleport"] let hasTeleportTag: Bool = event.tags.contains { tag in tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" @@ -297,11 +302,6 @@ extension ChatViewModel { geoNicknames[event.pubkey.lowercased()] = nick } - // If this pubkey is blocked, skip mapping, participants, and timeline - if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) { - return - } - // Store mapping for geohash DM initiation nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey From 6faa46a22ffb455e1f7ce2ced597b44f4736b0ab Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:41:37 +0700 Subject: [PATCH 6/7] increase limit to 1000 --- bitchat/Nostr/NostrRelayManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 1211c2ec..bbe13235 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -888,7 +888,7 @@ struct NostrFilter: Encodable { } // 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 { + static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter { var filter = NostrFilter() filter.kinds = [20000, 20001] filter.since = since?.timeIntervalSince1970.toInt() From 95a6ec7315cb08cf1d4cfc4e3daefd3ca691afd8 Mon Sep 17 00:00:00 2001 From: jack Date: Mon, 12 Jan 2026 09:56:51 -1000 Subject: [PATCH 7/7] add tests for geohash presence feature 34 tests covering: - NostrProtocol presence event creation - NostrFilter kind filtering - ChatViewModel presence handling - Privacy precision restrictions - Display logic for "? people" - Participant tracker integration Co-Authored-By: Claude Opus 4.5 --- bitchatTests/GeohashPresenceTests.swift | 567 ++++++++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 bitchatTests/GeohashPresenceTests.swift diff --git a/bitchatTests/GeohashPresenceTests.swift b/bitchatTests/GeohashPresenceTests.swift new file mode 100644 index 00000000..067fffa2 --- /dev/null +++ b/bitchatTests/GeohashPresenceTests.swift @@ -0,0 +1,567 @@ +// +// GeohashPresenceTests.swift +// bitchatTests +// +// Tests for the Geohash Presence (Kind 20001) feature. +// This is free and unencumbered software released into the public domain. +// + +import Testing +import Foundation +import Combine +@testable import bitchat + +// MARK: - NostrProtocol Presence Event Tests + +struct NostrProtocolPresenceTests { + + @Test func createGeohashPresenceEvent_hasCorrectKind() throws { + let identity = try makeTestIdentity() + let event = try NostrProtocol.createGeohashPresenceEvent( + geohash: "u4pruydq", + senderIdentity: identity + ) + + #expect(event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) + #expect(event.kind == 20001) + } + + @Test func createGeohashPresenceEvent_hasEmptyContent() throws { + let identity = try makeTestIdentity() + let event = try NostrProtocol.createGeohashPresenceEvent( + geohash: "u4pruydq", + senderIdentity: identity + ) + + #expect(event.content == "") + } + + @Test func createGeohashPresenceEvent_hasOnlyGeohashTag() throws { + let identity = try makeTestIdentity() + let event = try NostrProtocol.createGeohashPresenceEvent( + geohash: "u4pruydq", + senderIdentity: identity + ) + + // Should have exactly one tag: ["g", geohash] + #expect(event.tags.count == 1) + #expect(event.tags[0] == ["g", "u4pruydq"]) + } + + @Test func createGeohashPresenceEvent_noNicknameTag() throws { + let identity = try makeTestIdentity() + let event = try NostrProtocol.createGeohashPresenceEvent( + geohash: "u4pruydq", + senderIdentity: identity + ) + + // Should NOT contain nickname tag + let hasNicknameTag = event.tags.contains { $0.first == "n" } + #expect(!hasNicknameTag) + } + + @Test func createGeohashPresenceEvent_usesSenderPubkey() throws { + let identity = try makeTestIdentity() + let event = try NostrProtocol.createGeohashPresenceEvent( + geohash: "u4pruydq", + senderIdentity: identity + ) + + #expect(event.pubkey == identity.publicKeyHex) + } + + @Test func createGeohashPresenceEvent_isSigned() throws { + let identity = try makeTestIdentity() + let event = try NostrProtocol.createGeohashPresenceEvent( + geohash: "u4pruydq", + senderIdentity: identity + ) + + #expect(event.sig != nil && !event.sig!.isEmpty) + #expect(!event.id.isEmpty) + } + + @Test func createGeohashPresenceEvent_differentGeohashes() throws { + let identity = try makeTestIdentity() + + let event1 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87", senderIdentity: identity) + let event2 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw", senderIdentity: identity) + let event3 = try NostrProtocol.createGeohashPresenceEvent(geohash: "87yw7", senderIdentity: identity) + + #expect(event1.tags[0][1] == "87") + #expect(event2.tags[0][1] == "87yw") + #expect(event3.tags[0][1] == "87yw7") + } + + // MARK: - Helper + + private func makeTestIdentity() throws -> NostrIdentity { + // Generate a fresh test identity + return try NostrIdentity.generate() + } +} + +// MARK: - NostrFilter Presence Tests + +struct NostrFilterPresenceTests { + + @Test func geohashEphemeral_includesBothKinds() { + let filter = NostrFilter.geohashEphemeral("u4pruydq") + + #expect(filter.kinds?.contains(20000) == true) + #expect(filter.kinds?.contains(20001) == true) + } + + @Test func geohashEphemeral_hasLimit1000() { + let filter = NostrFilter.geohashEphemeral("u4pruydq") + + #expect(filter.limit == 1000) + } + + @Test func geohashEphemeral_respectsSinceParameter() { + let since = Date(timeIntervalSince1970: 1700000000) + let filter = NostrFilter.geohashEphemeral("u4pruydq", since: since) + + #expect(filter.since == 1700000000) + } +} + +// MARK: - ChatViewModel Presence Handling Tests + +@MainActor +struct ChatViewModelPresenceHandlingTests { + + @Test func handleNostrEvent_presenceUpdatesParticipantTracker() async { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + + // Set up the channel + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + + // Create a presence event (kind 20001) + var event = NostrEvent( + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + createdAt: Date(), + kind: .geohashPresence, + tags: [["g", geohash]], + content: "" + ) + event.id = "presence_evt_1" + event.sig = "sig" + + // Handle the event + viewModel.handleNostrEvent(event) + + // Allow async processing + try? await Task.sleep(nanoseconds: 50_000_000) + + // Participant should be recorded + let count = viewModel.geohashParticipantCount(for: geohash) + #expect(count >= 1) + } + + @Test func handleNostrEvent_presenceDoesNotAddToTimeline() async { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + + let initialMessageCount = viewModel.messages.count + + // Create a presence event (kind 20001) + var event = NostrEvent( + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + createdAt: Date(), + kind: .geohashPresence, + tags: [["g", geohash]], + content: "" + ) + event.id = "presence_evt_2" + event.sig = "sig" + + viewModel.handleNostrEvent(event) + + try? await Task.sleep(nanoseconds: 50_000_000) + + // Message count should NOT increase + #expect(viewModel.messages.count == initialMessageCount) + } + + @Test func handleNostrEvent_chatMessageUpdatesParticipant() async { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + + // Create a chat event (kind 20000) - NOT presence + var event = NostrEvent( + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + createdAt: Date(), + kind: .ephemeralEvent, + tags: [["g", geohash]], + content: "Hello world" + ) + event.id = "chat_evt_1" + event.sig = "sig" + + viewModel.handleNostrEvent(event) + + try? await Task.sleep(nanoseconds: 50_000_000) + + // Chat messages should also update participant count (not just presence) + let count = viewModel.geohashParticipantCount(for: geohash) + #expect(count >= 1) + } + + @Test func presenceEvent_hasDifferentKindThanChat() { + // Verify the two event kinds are distinct + let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue + let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue + + #expect(presenceKind != chatKind) + #expect(presenceKind == 20001) + #expect(chatKind == 20000) + } + + @Test func subscribeNostrEvent_acceptsPresenceKind() async { + let (viewModel, _) = makeTestableViewModel() + let geohash = "u4pruydq" + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) + + // Create presence event + var event = NostrEvent( + pubkey: "test1234test1234test1234test1234test1234test1234test1234test1234", + createdAt: Date(), + kind: .geohashPresence, + tags: [["g", geohash]], + content: "" + ) + event.id = "subscribe_presence_evt" + event.sig = "sig" + + // subscribeNostrEvent should accept kind 20001 + viewModel.subscribeNostrEvent(event) + + try? await Task.sleep(nanoseconds: 50_000_000) + + // Should record participant + let count = viewModel.geohashParticipantCount(for: geohash) + #expect(count >= 1) + } + + @Test func subscribeNostrEvent_presenceForNonActiveGeohash() async { + let (viewModel, _) = makeTestableViewModel() + let activeGeohash = "u4pruydq" + let otherGeohash = "87yw7" + + viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash))) + + // Create presence event for a DIFFERENT geohash + var event = NostrEvent( + pubkey: "other1234other1234other1234other1234other1234other1234other1234", + createdAt: Date(), + kind: .geohashPresence, + tags: [["g", otherGeohash]], + content: "" + ) + event.id = "other_geohash_presence" + event.sig = "sig" + + // Use subscribeNostrEvent with geohash parameter + viewModel.subscribeNostrEvent(event, gh: otherGeohash) + + try? await Task.sleep(nanoseconds: 50_000_000) + + // Should record for the other geohash + let count = viewModel.geohashParticipantCount(for: otherGeohash) + #expect(count >= 1) + } + + // MARK: - Test Helper + + private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) { + let keychain = MockKeychain() + let keychainHelper = MockKeychainHelper() + let idBridge = NostrIdentityBridge(keychain: keychainHelper) + let identityManager = MockIdentityManager(keychain) + let transport = MockTransport() + + let viewModel = ChatViewModel( + keychain: keychain, + idBridge: idBridge, + identityManager: identityManager, + transport: transport + ) + + return (viewModel, transport) + } +} + +// MARK: - Presence Privacy Tests + +struct GeohashPresencePrivacyTests { + + @Test func allowedPrecisions_onlyLowPrecision() { + // The allowed precisions for presence broadcasting should be: + // Region (2), Province (4), City (5) + // NOT Neighborhood (6), Block (7), Building (8+) + + let regionPrecision = GeohashChannelLevel.region.precision + let provincePrecision = GeohashChannelLevel.province.precision + let cityPrecision = GeohashChannelLevel.city.precision + let neighborhoodPrecision = GeohashChannelLevel.neighborhood.precision + let blockPrecision = GeohashChannelLevel.block.precision + let buildingPrecision = GeohashChannelLevel.building.precision + + #expect(regionPrecision == 2) + #expect(provincePrecision == 4) + #expect(cityPrecision == 5) + #expect(neighborhoodPrecision == 6) + #expect(blockPrecision == 7) + #expect(buildingPrecision == 8) + + // High precision channels should NOT receive presence broadcasts + #expect(neighborhoodPrecision > 5) + #expect(blockPrecision > 5) + #expect(buildingPrecision > 5) + } + + @Test func geohashLengthDeterminesPrecision() { + // Verify geohash length maps to expected precision + #expect("87".count == GeohashChannelLevel.region.precision) + #expect("87yw".count == GeohashChannelLevel.province.precision) + #expect("87yw7".count == GeohashChannelLevel.city.precision) + #expect("87yw7t".count == GeohashChannelLevel.neighborhood.precision) + #expect("87yw7tc".count == GeohashChannelLevel.block.precision) + #expect("87yw7tcx".count == GeohashChannelLevel.building.precision) + } + + @Test func highPrecisionGeohash_isPrivacySensitive() { + // Helper to check if a geohash is "high precision" (privacy sensitive) + func isHighPrecision(_ geohash: String) -> Bool { + geohash.count >= 6 + } + + // Low precision - OK to broadcast presence + #expect(!isHighPrecision("87")) // region + #expect(!isHighPrecision("87yw")) // province + #expect(!isHighPrecision("87yw7")) // city + + // High precision - should NOT broadcast presence + #expect(isHighPrecision("87yw7t")) // neighborhood + #expect(isHighPrecision("87yw7tc")) // block + #expect(isHighPrecision("87yw7tcx")) // building + } +} + +// MARK: - Display Logic Tests + +struct LocationChannelsDisplayLogicTests { + + @Test func displayLogic_highPrecisionZeroCount_showsUnknown() { + // Test the logic that determines "?" vs actual count + // High precision + count 0 = "?" + + let shouldShowUnknown = shouldShowUnknownCount( + level: .neighborhood, + count: 0 + ) + #expect(shouldShowUnknown) + } + + @Test func displayLogic_highPrecisionNonZeroCount_showsActual() { + // High precision + count > 0 = show actual + let shouldShowUnknown = shouldShowUnknownCount( + level: .neighborhood, + count: 5 + ) + #expect(!shouldShowUnknown) + } + + @Test func displayLogic_lowPrecisionZeroCount_showsActual() { + // Low precision + count 0 = show "0" (not "?") + let shouldShowUnknown = shouldShowUnknownCount( + level: .city, + count: 0 + ) + #expect(!shouldShowUnknown) + } + + @Test func displayLogic_lowPrecisionNonZeroCount_showsActual() { + // Low precision + count > 0 = show actual + let shouldShowUnknown = shouldShowUnknownCount( + level: .region, + count: 10 + ) + #expect(!shouldShowUnknown) + } + + @Test func displayLogic_allHighPrecisionLevels() { + // All high precision levels with 0 should show "?" + let highPrecisionLevels: [GeohashChannelLevel] = [.neighborhood, .block, .building] + + for level in highPrecisionLevels { + let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0) + #expect(shouldShowUnknown, "Level \(level) with count 0 should show unknown") + } + } + + @Test func displayLogic_allLowPrecisionLevels() { + // All low precision levels with 0 should show actual count + let lowPrecisionLevels: [GeohashChannelLevel] = [.region, .province, .city] + + for level in lowPrecisionLevels { + let shouldShowUnknown = shouldShowUnknownCount(level: level, count: 0) + #expect(!shouldShowUnknown, "Level \(level) with count 0 should show actual count") + } + } + + @Test func displayLogic_bookmarkHighPrecision() { + // Bookmarks use geohash length to determine precision + #expect(shouldShowUnknownForBookmark(geohash: "87yw7t", count: 0)) // len 6 + #expect(shouldShowUnknownForBookmark(geohash: "87yw7tc", count: 0)) // len 7 + #expect(shouldShowUnknownForBookmark(geohash: "87yw7tcx", count: 0)) // len 8 + } + + @Test func displayLogic_bookmarkLowPrecision() { + #expect(!shouldShowUnknownForBookmark(geohash: "87", count: 0)) // len 2 + #expect(!shouldShowUnknownForBookmark(geohash: "87yw", count: 0)) // len 4 + #expect(!shouldShowUnknownForBookmark(geohash: "87yw7", count: 0)) // len 5 + } + + // MARK: - Helpers (mirror the logic from LocationChannelsSheet) + + private func shouldShowUnknownCount(level: GeohashChannelLevel, count: Int) -> Bool { + let isHighPrecision = (level == .neighborhood || level == .block || level == .building) + return isHighPrecision && count == 0 + } + + private func shouldShowUnknownForBookmark(geohash: String, count: Int) -> Bool { + let isHighPrecision = (geohash.count >= 6) + return isHighPrecision && count == 0 + } +} + +// MARK: - Event Kind Tests + +struct NostrEventKindTests { + + @Test func eventKind_geohashPresence_is20001() { + #expect(NostrProtocol.EventKind.geohashPresence.rawValue == 20001) + } + + @Test func eventKind_ephemeralEvent_is20000() { + #expect(NostrProtocol.EventKind.ephemeralEvent.rawValue == 20000) + } + + @Test func eventKind_presenceIsEphemeral() { + // Both 20000 and 20001 are in the ephemeral range (20000-29999) + let presenceKind = NostrProtocol.EventKind.geohashPresence.rawValue + let chatKind = NostrProtocol.EventKind.ephemeralEvent.rawValue + + #expect(presenceKind >= 20000 && presenceKind < 30000) + #expect(chatKind >= 20000 && chatKind < 30000) + } +} + +// MARK: - Participant Tracker Presence Integration Tests + +@MainActor +struct ParticipantTrackerPresenceTests { + + @Test func recordParticipant_fromPresenceEvent_countsParticipant() async { + let tracker = GeohashParticipantTracker() + let context = PresenceTestParticipantContext() + tracker.configure(context: context) + + let geohash = "87yw7" + tracker.setActiveGeohash(geohash) + + // Simulate recording from a presence event + tracker.recordParticipant(pubkeyHex: "presence_user_1") + + #expect(tracker.participantCount(for: geohash) == 1) + } + + @Test func recordParticipant_multiplePresenceEvents_countsUnique() async { + let tracker = GeohashParticipantTracker() + let context = PresenceTestParticipantContext() + tracker.configure(context: context) + + let geohash = "87yw7" + tracker.setActiveGeohash(geohash) + + // Multiple presence events from same user = 1 participant + tracker.recordParticipant(pubkeyHex: "user_a") + tracker.recordParticipant(pubkeyHex: "user_a") + tracker.recordParticipant(pubkeyHex: "user_a") + + #expect(tracker.participantCount(for: geohash) == 1) + + // Different user = 2 participants + tracker.recordParticipant(pubkeyHex: "user_b") + + #expect(tracker.participantCount(for: geohash) == 2) + } + + @Test func recordParticipant_nonActiveGeohash_stillCounts() async { + let tracker = GeohashParticipantTracker() + let context = PresenceTestParticipantContext() + tracker.configure(context: context) + + // Active geohash is different from where we're recording + tracker.setActiveGeohash("active_gh") + + // Record to a non-active geohash (like when sampling nearby channels) + tracker.recordParticipant(pubkeyHex: "nearby_user", geohash: "other_gh") + + #expect(tracker.participantCount(for: "other_gh") == 1) + #expect(tracker.participantCount(for: "active_gh") == 0) + } + + @Test func objectWillChange_firesOnNonActiveGeohashUpdate() async { + let tracker = GeohashParticipantTracker() + let context = PresenceTestParticipantContext() + tracker.configure(context: context) + + tracker.setActiveGeohash("active_gh") + + var changeCount = 0 + let cancellable = tracker.objectWillChange.sink { _ in + changeCount += 1 + } + + // Record to non-active geohash + tracker.recordParticipant(pubkeyHex: "user1", geohash: "other_gh") + + // Should fire objectWillChange even for non-active geohash + #expect(changeCount >= 1) + + _ = cancellable // Keep alive + } +} + +// MARK: - Mock for Participant Context (Presence Tests) + +@MainActor +private final class PresenceTestParticipantContext: GeohashParticipantContext { + var blockedPubkeys: Set = [] + var nicknameMap: [String: String] = [:] + var selfPubkey: String? + + func displayNameForPubkey(_ pubkeyHex: String) -> String { + let suffix = String(pubkeyHex.suffix(4)) + if let s = selfPubkey, pubkeyHex.lowercased() == s.lowercased() { + return "me#\(suffix)" + } + if let nick = nicknameMap[pubkeyHex.lowercased()] { + return "\(nick)#\(suffix)" + } + return "anon#\(suffix)" + } + + func isBlocked(_ pubkeyHexLowercased: String) -> Bool { + blockedPubkeys.contains(pubkeyHexLowercased.lowercased()) + } +}