mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:45:19 +00:00
Merge branch 'main' into source-routing-packet-format
This commit is contained in:
@@ -63,6 +63,10 @@ struct BitchatApp: App {
|
|||||||
|
|
||||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||||
NetworkActivationService.shared.start()
|
NetworkActivationService.shared.start()
|
||||||
|
|
||||||
|
// Start presence service (will wait for Tor readiness)
|
||||||
|
GeohashPresenceService.shared.start()
|
||||||
|
|
||||||
// Check for shared content
|
// Check for shared content
|
||||||
checkForSharedContent()
|
checkForSharedContent()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct NostrProtocol {
|
|||||||
case seal = 13 // NIP-17 sealed event
|
case seal = 13 // NIP-17 sealed event
|
||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
|
case geohashPresence = 20001
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
@@ -125,6 +126,24 @@ struct NostrProtocol {
|
|||||||
return try event.sign(with: schnorrKey)
|
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.
|
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||||
static func createGeohashTextNote(
|
static func createGeohashTextNote(
|
||||||
content: String,
|
content: String,
|
||||||
|
|||||||
@@ -887,10 +887,10 @@ struct NostrFilter: Encodable {
|
|||||||
return filter
|
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 {
|
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
|
||||||
var filter = NostrFilter()
|
var filter = NostrFilter()
|
||||||
filter.kinds = [20000]
|
filter.kinds = [20000, 20001]
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
filter.since = since?.timeIntervalSince1970.toInt()
|
||||||
filter.tagFilters = ["g": [geohash]]
|
filter.tagFilters = ["g": [geohash]]
|
||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ public final class GeohashParticipantTracker: ObservableObject {
|
|||||||
map[key] = Date()
|
map[key] = Date()
|
||||||
participants[geohash] = map
|
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
|
// Only refresh visible list if this geohash is currently active
|
||||||
if activeGeohash == geohash {
|
if activeGeohash == geohash {
|
||||||
refresh()
|
refresh()
|
||||||
|
|||||||
@@ -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<AnyCancellable>()
|
||||||
|
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<Int> = [
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
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)
|
!deduplicationService.hasProcessedNostrEvent(event.id)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
@@ -86,6 +87,11 @@ extension ChatViewModel {
|
|||||||
// Update participants last-seen for this pubkey
|
// Update participants last-seen for this pubkey
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.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
|
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
||||||
let hasTeleportTag = event.tags.contains(where: { tag in
|
let hasTeleportTag = event.tags.contains(where: { tag in
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||||
@@ -239,8 +245,9 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleNostrEvent(_ event: NostrEvent) {
|
func handleNostrEvent(_ event: NostrEvent) {
|
||||||
// Only handle ephemeral kind 20000 with matching tag
|
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||||
|
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||||
|
|
||||||
// Deduplicate
|
// Deduplicate
|
||||||
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
||||||
@@ -250,6 +257,11 @@ extension ChatViewModel {
|
|||||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
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"]
|
// Track teleport tag for participants – only our format ["t", "teleport"]
|
||||||
let hasTeleportTag: Bool = event.tags.contains { tag in
|
let hasTeleportTag: Bool = event.tags.contains { tag in
|
||||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||||
@@ -273,6 +285,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
|
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||||
if isSelf {
|
if isSelf {
|
||||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
@@ -287,17 +302,14 @@ extension ChatViewModel {
|
|||||||
geoNicknames[event.pubkey.lowercased()] = nick
|
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
|
// Store mapping for geohash DM initiation
|
||||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||||
|
|
||||||
// Update participants last-seen for this pubkey
|
// If presence heartbeat (Kind 20001), stop here - no content to display
|
||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||||
let content = event.content
|
let content = event.content
|
||||||
@@ -464,7 +476,8 @@ extension ChatViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
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
|
// Compute current participant count (5-minute window) BEFORE updating with this event
|
||||||
let existingCount = participantTracker.participantCount(for: gh)
|
let existingCount = participantTracker.participantCount(for: gh)
|
||||||
|
|||||||
@@ -40,10 +40,31 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
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)
|
return rowTitle(label: level.displayName, count: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
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)
|
return rowTitle(label: "#\(geohash)", count: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<String> = []
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": <timestamp>,
|
||||||
|
"tags": [
|
||||||
|
["g", "<geohash>"]
|
||||||
|
],
|
||||||
|
"content": "",
|
||||||
|
"pubkey": "<geohash_derived_pubkey>",
|
||||||
|
"id": "<event_id>",
|
||||||
|
"sig": "<signature>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* **`content`**: Must be empty string.
|
||||||
|
* **`tags`**: Must include `["g", "<geohash>"]`. Should NOT include `["n", "<nickname>"]`.
|
||||||
|
* **`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`: `["<geohash>"]`
|
||||||
|
|
||||||
|
### 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.
|
||||||
Reference in New Issue
Block a user