diff --git a/bitchat/Services/GeohashParticipantTracker.swift b/bitchat/Services/GeohashParticipantTracker.swift new file mode 100644 index 00000000..1bd2df02 --- /dev/null +++ b/bitchat/Services/GeohashParticipantTracker.swift @@ -0,0 +1,159 @@ +// +// GeohashParticipantTracker.swift +// bitchat +// +// Tracks participants in geohash-based location channels. +// This is free and unencumbered software released into the public domain. +// + +import Foundation + +/// Represents a participant in a geohash channel +public struct GeoPerson: Identifiable, Equatable, Sendable { + public let id: String // pubkey hex (lowercased) + public let displayName: String + public let lastSeen: Date + + public init(id: String, displayName: String, lastSeen: Date) { + self.id = id + self.displayName = displayName + self.lastSeen = lastSeen + } +} + +/// Protocol for resolving display names and checking block status +@MainActor +public protocol GeohashParticipantContext: AnyObject { + /// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4") + func displayNameForPubkey(_ pubkeyHex: String) -> String + /// Returns true if the pubkey is blocked + func isBlocked(_ pubkeyHexLowercased: String) -> Bool +} + +/// Tracks participants across multiple geohash channels +@MainActor +public final class GeohashParticipantTracker: ObservableObject { + + /// Activity cutoff duration (defaults to 5 minutes) + public let activityCutoff: TimeInterval + + /// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]] + private var participants: [String: [String: Date]] = [:] + + /// Currently visible people for the active geohash + @Published public private(set) var visiblePeople: [GeoPerson] = [] + + /// The currently active geohash (if any) + private var activeGeohash: String? + + /// Context for display name resolution and block checking + private weak var context: GeohashParticipantContext? + + /// Timer for periodic refresh + private var refreshTimer: Timer? + + public init(activityCutoff: TimeInterval = -300) { // default 5 minutes + self.activityCutoff = activityCutoff + } + + /// Configure with a context provider + public func configure(context: GeohashParticipantContext) { + self.context = context + } + + /// Set the currently active geohash + public func setActiveGeohash(_ geohash: String?) { + activeGeohash = geohash + if geohash == nil { + visiblePeople = [] + } else { + refresh() + } + } + + /// Record activity from a participant in the current active geohash + public func recordParticipant(pubkeyHex: String) { + guard let gh = activeGeohash else { return } + recordParticipant(pubkeyHex: pubkeyHex, geohash: gh) + } + + /// Record activity from a participant in a specific geohash + public func recordParticipant(pubkeyHex: String, geohash: String) { + let key = pubkeyHex.lowercased() + var map = participants[geohash] ?? [:] + map[key] = Date() + participants[geohash] = map + + // Only refresh visible list if this geohash is currently active + if activeGeohash == geohash { + refresh() + } + } + + /// Remove a participant from all geohashes (used when blocking) + public func removeParticipant(pubkeyHex: String) { + let key = pubkeyHex.lowercased() + for (gh, var map) in participants { + map.removeValue(forKey: key) + participants[gh] = map + } + refresh() + } + + /// Get participant count for a specific geohash + public func participantCount(for geohash: String) -> Int { + let cutoff = Date().addingTimeInterval(activityCutoff) + let map = participants[geohash] ?? [:] + return map.values.filter { $0 >= cutoff }.count + } + + /// Get the visible people list for the active geohash (read-only query) + public func getVisiblePeople() -> [GeoPerson] { + guard let gh = activeGeohash, let context = context else { return [] } + let cutoff = Date().addingTimeInterval(activityCutoff) + let map = (participants[gh] ?? [:]) + .filter { $0.value >= cutoff } + .filter { !context.isBlocked($0.key) } + + return map + .map { (pub, seen) in + GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen) + } + .sorted { $0.lastSeen > $1.lastSeen } + } + + /// Refresh the visible people list + public func refresh() { + visiblePeople = getVisiblePeople() + } + + /// Start the periodic refresh timer + public func startRefreshTimer(interval: TimeInterval = 30.0) { + stopRefreshTimer() + refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + } + + /// Stop the periodic refresh timer + public func stopRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + /// Clear all participant data + public func clear() { + participants.removeAll() + visiblePeople = [] + } + + /// Clear participant data for a specific geohash + public func clear(geohash: String) { + participants.removeValue(forKey: geohash) + if activeGeohash == geohash { + visiblePeople = [] + } + } +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index cea5e752..bc62e4dc 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -92,7 +92,7 @@ import UniformTypeIdentifiers /// Manages the application state and business logic for BitChat. /// Acts as the primary coordinator between UI components and backend services, /// implementing the BitchatDelegate protocol to handle network events. -final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider { +final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider, GeohashParticipantContext { // Precompiled regexes and detectors reused across formatting private enum Regexes { static let hashtag: NSRegularExpression = { @@ -419,10 +419,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time private var lastPublicActivityNotifyAt: [String: Date] = [:] private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds - // Geohash participants (per geohash: pubkey -> lastSeen) - private var geoParticipants: [String: [String: Date]] = [:] - @Published private(set) var geohashPeople: [GeoPerson] = [] - private var geoParticipantsTimer: Timer? = nil + // Geohash participant tracker + let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds) // Participants who indicated they teleported (by tag in their events) @Published private(set) var teleportedGeo: Set = [] // lowercased pubkey hex // Sampling subscriptions for multiple geohashes (when channel sheet is open) @@ -526,6 +524,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // Wire up dependencies self.commandProcessor.chatViewModel = self + self.participantTracker.configure(context: self) // Subscribe to privateChatManager changes to trigger UI updates privateChatManager.objectWillChange @@ -533,6 +532,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv self?.objectWillChange.send() } .store(in: &cancellables) + + // Subscribe to participantTracker changes to trigger UI updates + participantTracker.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) self.commandProcessor.meshService = meshService loadNickname() @@ -873,7 +879,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv return } // Ensure participant decay timer is running - startGeoParticipantsTimer() + participantTracker.startRefreshTimer() // Unsubscribe + resubscribe NostrRelayManager.shared.unsubscribe(id: subID) let filter = NostrFilter.geohashEphemeral( @@ -932,7 +938,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey // Update participants last-seen for this pubkey - recordGeoParticipant(pubkeyHex: event.pubkey) + participantTracker.recordParticipant(pubkeyHex: event.pubkey) // Track teleported tag (only our format ["t","teleport"]) for icon state let hasTeleportTag = event.tags.contains(where: { tag in @@ -1479,7 +1485,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv } // Track ourselves as active participant - recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session) @@ -1514,8 +1520,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv if emptyMesh > 0 { SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) } - stopGeoParticipantsTimer() - geohashPeople = [] + participantTracker.stopRefreshTimer() + participantTracker.setActiveGeohash(nil) teleportedGeo.removeAll() case .location: refreshVisibleMessages(from: channel) @@ -1536,16 +1542,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv geoDmSubscriptionID = nil } currentGeohash = nil + participantTracker.setActiveGeohash(nil) // Reset nickname cache for geochat participants geoNicknames.removeAll() - geohashPeople = [] - + guard case .location(let ch) = channel else { return } currentGeohash = ch.geohash + participantTracker.setActiveGeohash(ch.geohash) // Ensure self appears immediately in the people list; mark teleported state only when truly teleported if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - recordGeoParticipant(pubkeyHex: id.publicKeyHex) + participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex) let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } let key = id.publicKeyHex.lowercased() @@ -1559,7 +1566,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv let subID = "geo-\(ch.geohash)" geoSubscriptionID = subID - startGeoParticipantsTimer() + participantTracker.startRefreshTimer() let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) @@ -1629,7 +1636,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey // Update participants last-seen for this pubkey - recordGeoParticipant(pubkeyHex: event.pubkey) + participantTracker.recordParticipant(pubkeyHex: event.pubkey) let senderName = displayNameForNostrPubkey(event.pubkey) let content = event.content @@ -1811,49 +1818,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv } // MARK: - Geohash Participants - struct GeoPerson: Identifiable, Equatable { - let id: String // pubkey hex (lowercased) - let displayName: String - let lastSeen: Date - } - - private func recordGeoParticipant(pubkeyHex: String) { - guard let gh = currentGeohash else { return } - let key = pubkeyHex.lowercased() - var map = geoParticipants[gh] ?? [:] - map[key] = Date() - geoParticipants[gh] = map - refreshGeohashPeople() - } - - private func recordGeoParticipant(pubkeyHex: String, geohash: String) { - let key = pubkeyHex.lowercased() - var map = geoParticipants[geohash] ?? [:] - map[key] = Date() - geoParticipants[geohash] = map - // Only refresh list if this geohash is currently selected - if currentGeohash == geohash { - refreshGeohashPeople() - } - } - - private func refreshGeohashPeople() { - guard let gh = currentGeohash else { geohashPeople = []; return } - let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) - var map = geoParticipants[gh] ?? [:] - // Prune expired entries - map = map.filter { $0.value >= cutoff } - // Remove blocked Nostr pubkeys - map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) } - geoParticipants[gh] = map - // Build display list - let people = map - .map { (pub, seen) in - GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) - } - .sorted { $0.lastSeen > $1.lastSeen } - geohashPeople = people - } @MainActor func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool { @@ -1878,33 +1842,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv return false } - private func startGeoParticipantsTimer() { - stopGeoParticipantsTimer() - geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.refreshGeohashPeople() - } - } + // MARK: - Public helpers + + /// Published geohash people list for SwiftUI observation + var geohashPeople: [GeoPerson] { + participantTracker.visiblePeople } - private func stopGeoParticipantsTimer() { - geoParticipantsTimer?.invalidate() - geoParticipantsTimer = nil - } - - // MARK: - Public helpers /// Return the current, pruned, sorted people list for the active geohash without mutating state. @MainActor func visibleGeohashPeople() -> [GeoPerson] { - guard let gh = currentGeohash else { return [] } - let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) - let map = (geoParticipants[gh] ?? [:]) - .filter { $0.value >= cutoff } - .filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) } - let people = map - .map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) } - .sorted { $0.lastSeen > $1.lastSeen } - return people + participantTracker.getVisiblePeople() } /// CommandContextProvider conformance - returns visible geo participants @@ -1914,9 +1862,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv /// Returns the current participant count for a specific geohash, using the 5-minute activity window. @MainActor func geohashParticipantCount(for geohash: String) -> Int { - let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) - let map = geoParticipants[geohash] ?? [:] - return map.values.filter { $0 >= cutoff }.count + participantTracker.participantCount(for: geohash) + } + + // MARK: - GeohashParticipantContext Protocol + + func displayNameForPubkey(_ pubkeyHex: String) -> String { + displayNameForNostrPubkey(pubkeyHex) + } + + func isBlocked(_ pubkeyHexLowercased: String) -> Bool { + identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) } // Geohash block helpers @@ -1928,13 +1884,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { let hex = pubkeyHexLowercased.lowercased() identityManager.setNostrBlocked(hex, isBlocked: true) - + // Remove from participants for all geohashes - for (gh, var map) in geoParticipants { - map.removeValue(forKey: hex) - geoParticipants[gh] = map - } - refreshGeohashPeople() + participantTracker.removeParticipant(pubkeyHex: hex) // Remove their public messages from current geohash timeline and visible list if let gh = currentGeohash { @@ -2022,13 +1974,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv private func subscribeNostrEvent(_ event: NostrEvent, gh: String) { guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } - + // Compute current participant count (5-minute window) BEFORE updating with this event - let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) - let existingCount = geoParticipants[gh]?.values.filter { $0 >= cutoff }.count ?? 0 - + let existingCount = participantTracker.participantCount(for: gh) + // Update participants for this specific geohash - recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) + participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh) // Notify only on rising-edge: previously zero people, now someone sends a chat let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) @@ -3229,7 +3180,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv NostrRelayManager.shared.sendEvent(event, to: targetRelays) } // Track ourselves as active participant - self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + self.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) } catch { SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session) self.addSystemMessage( diff --git a/bitchatTests/GeohashParticipantTrackerTests.swift b/bitchatTests/GeohashParticipantTrackerTests.swift new file mode 100644 index 00000000..d05b8171 --- /dev/null +++ b/bitchatTests/GeohashParticipantTrackerTests.swift @@ -0,0 +1,293 @@ +// +// GeohashParticipantTrackerTests.swift +// bitchatTests +// +// Tests for GeohashParticipantTracker. +// This is free and unencumbered software released into the public domain. +// + +import Testing +import Foundation +@testable import bitchat + +/// Mock context for testing +@MainActor +final class MockParticipantContext: GeohashParticipantContext { + var blockedPubkeys: Set = [] + var nicknameMap: [String: String] = [:] + var selfPubkey: String? + + func displayNameForPubkey(_ pubkeyHex: String) -> String { + let suffix = String(pubkeyHex.suffix(4)) + if let self = selfPubkey, pubkeyHex.lowercased() == self.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()) + } +} + +@MainActor +struct GeohashParticipantTrackerTests { + + // MARK: - Basic Recording Tests + + @Test func recordParticipant_addsToActiveGeohash() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "deadbeef1234") + + #expect(tracker.participantCount(for: "abc123") == 1) + } + + @Test func recordParticipant_noActiveGeohash_noOp() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + // No active geohash set + + tracker.recordParticipant(pubkeyHex: "deadbeef1234") + + // Should not throw or crash + #expect(tracker.participantCount(for: "abc123") == 0) + } + + @Test func recordParticipant_specificGeohash() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1") + tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2") + + #expect(tracker.participantCount(for: "geo1") == 1) + #expect(tracker.participantCount(for: "geo2") == 1) + } + + @Test func recordParticipant_updatesLastSeen() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1") + // Small delay and record again + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + tracker.recordParticipant(pubkeyHex: "pubkey1") + + // Should still count as 1 participant (updated, not duplicated) + #expect(tracker.participantCount(for: "abc123") == 1) + } + + @Test func recordParticipant_lowercasesPubkey() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "DEADBEEF") + tracker.recordParticipant(pubkeyHex: "deadbeef") + + // Should be treated as same participant + #expect(tracker.participantCount(for: "abc123") == 1) + } + + // MARK: - Visible People Tests + + @Test func getVisiblePeople_returnsActiveGeohashParticipants() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1") + tracker.recordParticipant(pubkeyHex: "pubkey2") + + let people = tracker.getVisiblePeople() + #expect(people.count == 2) + } + + @Test func getVisiblePeople_excludesBlockedParticipants() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + context.blockedPubkeys = ["pubkey2"] + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1") + tracker.recordParticipant(pubkeyHex: "pubkey2") + + let people = tracker.getVisiblePeople() + #expect(people.count == 1) + #expect(people.first?.id == "pubkey1") + } + + @Test func getVisiblePeople_usesDisplayNameFromContext() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + context.nicknameMap = ["pubkey1234": "alice"] + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1234") + + let people = tracker.getVisiblePeople() + #expect(people.count == 1) + #expect(people.first?.displayName == "alice#1234") + } + + @Test func getVisiblePeople_sortedByLastSeen() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "older") + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + tracker.recordParticipant(pubkeyHex: "newer") + + let people = tracker.getVisiblePeople() + #expect(people.count == 2) + #expect(people.first?.id == "newer") + #expect(people.last?.id == "older") + } + + @Test func getVisiblePeople_emptyWhenNoActiveGeohash() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123") + + let people = tracker.getVisiblePeople() + #expect(people.isEmpty) + } + + // MARK: - Activity Cutoff Tests + + @Test func participantCount_excludesExpiredEntries() async { + // Use a very short cutoff for testing + let tracker = GeohashParticipantTracker(activityCutoff: -0.05) // 50ms cutoff + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1") + + // Should be counted immediately + #expect(tracker.participantCount(for: "abc123") == 1) + + // Wait for expiry + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + + // Should be expired now + #expect(tracker.participantCount(for: "abc123") == 0) + } + + // MARK: - Remove Participant Tests + + @Test func removeParticipant_removesFromAllGeohashes() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1") + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo2") + tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo1") + + tracker.removeParticipant(pubkeyHex: "pubkey1") + + #expect(tracker.participantCount(for: "geo1") == 1) + #expect(tracker.participantCount(for: "geo2") == 0) + } + + // MARK: - Clear Tests + + @Test func clear_removesAllData() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1") + tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "other") + + tracker.clear() + + #expect(tracker.participantCount(for: "abc123") == 0) + #expect(tracker.participantCount(for: "other") == 0) + #expect(tracker.visiblePeople.isEmpty) + } + + @Test func clearGeohash_removesOnlySpecificGeohash() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1") + tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2") + + tracker.clear(geohash: "geo1") + + #expect(tracker.participantCount(for: "geo1") == 0) + #expect(tracker.participantCount(for: "geo2") == 1) + } + + // MARK: - Set Active Geohash Tests + + @Test func setActiveGeohash_clearsVisiblePeopleWhenNil() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + tracker.recordParticipant(pubkeyHex: "pubkey1") + + #expect(!tracker.visiblePeople.isEmpty) + + tracker.setActiveGeohash(nil) + + #expect(tracker.visiblePeople.isEmpty) + } + + @Test func setActiveGeohash_refreshesVisiblePeople() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + + // Pre-populate a geohash + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123") + + // Set it as active + tracker.setActiveGeohash("abc123") + + #expect(tracker.visiblePeople.count == 1) + } + + // MARK: - GeoPerson Tests + + @Test func geoPerson_identifiable() async { + let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date()) + let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date()) + let person3 = GeoPerson(id: "xyz", displayName: "bob", lastSeen: Date()) + + #expect(person1.id == person2.id) + #expect(person1.id != person3.id) + } + + @Test func geoPerson_equatable() async { + let date = Date() + let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date) + let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date) + + #expect(person1 == person2) + } +}