How the Location Presence Store and Geohash-Based Peer Discovery Work in Bitchat

Bitchat implements a geohash-driven rendezvous model that uses ephemeral Nostr events and an in-memory presence store to discover nearby peers without exposing precise GPS coordinates.

The permissionlesstech/bitchat repository combines a lightweight LocationPresenceStore with Nostr protocol filters to enable location-based chat. This architecture allows users to see who is active in a specific geographical cell while maintaining privacy through low-precision geohash broadcasting and ephemeral identity keys.

The Location Presence Store Architecture

The LocationPresenceStore class serves as the central in-memory cache for tracking peer activity within geohash cells. Located in bitchat/App/LocationPresenceStore.swift, this ObservableObject publishes updates to SwiftUI views whenever the local user changes cells or when new peers announce their presence.

In-Memory Caching and Capacity Management

The store maintains three critical data structures that enforce strict memory bounds defined in TransportConfig:

  • Current geohash tracking: The setCurrentGeohash() method updates the active cell and immediately clears both nickname and teleported maps to prevent stale data leakage between locations.
  • Nickname mapping: setNickname(_:for:) and replaceGeoNicknames(_:) manage the geoNicknameCapacity limit, evicting older entries on a first-in-first-out basis when the bound is exceeded.
  • Teleported participants: markTeleported(_:) and retainTeleportedGeo(keeping:) maintain a bounded list of recently seen peers who are not actively chatting, using the separate teleportedGeoCapacity limit.

All keys are lower-cased to ensure case-insensitive matching, and nicknames undergo normalizedNickname processing before storage.

Observable State Updates

Because LocationPresenceStore conforms to ObservableObject, UI components like ChatViewModel and PeerListModel receive automatic updates through @Published properties. This reactive pattern ensures that peer lists and presence counters refresh immediately when the underlying geohash data changes.

Geohash-Based Peer Discovery Pipeline

The discovery mechanism relies on three coordinated components that subscribe to Nostr relays, parse incoming events, and maintain temporal presence state.

GeohashSubscriptionManager and Nostr Filters

The GeohashSubscriptionManager (in bitchat/ViewModels/GeohashSubscriptionManager.swift) creates relay filters that include the tag ["g", <geohash>]. For every incoming event matching these filters, the manager:

  1. Extracts the geohash tag from the event
  2. Updates the LocationPresenceStore via setNickname() or markTeleported()
  3. Forwards the event to higher-level chat services

This component handles both chat messages (kind = 20000) and presence announcements (kind = 20001).

GeoPresenceTracker and Last-Seen Tracking

The GeoPresenceTracker (in bitchat/ViewModels/GeoPresenceTracker.swift) maintains an in-memory map of pubkey → lastSeen timestamps for each geohash cell. It prunes entries that exceed a configurable timeout threshold and exposes counters that drive UI elements displaying "X people here". Unlike the store, which tracks nicknames, the tracker focuses strictly on temporal presence validation.

Ephemeral Presence Events (Kind 20001)

The NostrProtocol.createGeohashPresenceEvent method (defined in bitchat/Models/NostrProtocol.swift) generates ephemeral presence events with the following characteristics:

  • Event kind: 20001 (EventKind.geohashPresence)
  • Content: Empty
  • Tags: Single ["g", <geohash>] tag
  • Signing: Uses a geohash-derived ephemeral key so each cell maintains a distinct identity

This design prevents correlation attacks while allowing relays to deduplicate multiple presence announcements from the same user within a cell.

Discovery Workflow in Practice

The complete peer discovery flow operates as follows:

  1. Channel publication: On app launch or location update, LocationChannelManager publishes available geohash channels based on the device's current position.
  2. Filter registration: GeohashSubscriptionManager creates Nostr filters for each cell and registers them with active relay connections.
  3. Heartbeat emission: The GeohashPresenceService calls startGlobalPresenceHeartbeat(), iterating over current geohashes and broadcasting signed presence events every 30 seconds (with randomized delays per the specification in docs/GeohashPresenceSpec.md).
  4. Store updates: Incoming events route to LocationPresenceStore methods based on event type, updating nickname maps or teleported sets accordingly.
  5. UI rendering: SwiftUI views observe the store's published properties and render participant lists, respecting the capacity limits enforced by TransportConfig.

Implementation Examples

Creating and broadcasting a presence event for a specific geohash cell:

let myIdentity = NostrIdentity.generate()
let presence = try NostrProtocol.createGeohashPresenceEvent(
    geohash: "9q8yy",
    senderIdentity: myIdentity
)
// presence.kind == 20001, empty content, only ["g", "9q8yy"] tag

Implementing the global presence heartbeat:

func startGlobalPresenceHeartbeat() {
    Task {
        for await channel in LocationChannelManager.shared.availableChannels {
            guard let geohash = channel.geohash else { continue }
            try await NostrRelay.publish(event: 
                try NostrProtocol.createGeohashPresenceEvent(
                    geohash: geohash,
                    senderIdentity: myIdentity
                )
            )
            try await Task.sleep(seconds: 30)
        }
    }
}

Handling incoming chat messages to update the presence store:

func handleIncoming(event: NostrEvent) {
    guard let geohash = event.tags.first(where: { $0.first == "g" })?.last else { return }
    locationPresenceStore.setCurrentGeohash(geohash)
    
    if let nickname = event.tags.first(where: { $0.first == "n" })?.last {
        locationPresenceStore.setNickname(nickname, for: event.pubkey)
    }
}

Pruning the store to retain only visible participants:

let visiblePubkeys: Set<String> = ["abcd1234", "deadbeef"]
locationPresenceStore.retainGeoNicknames(keeping: visiblePubkeys)
locationPresenceStore.retainTeleportedGeo(keeping: visiblePubkeys)

Summary

  • LocationPresenceStore (bitchat/App/LocationPresenceStore.swift) provides a bounded, observable cache for geohash-based peer data with automatic cleanup on cell transitions.
  • GeohashSubscriptionManager subscribes to Nostr filters tagged with specific geohashes and routes events to the presence store.
  • GeoPresenceTracker maintains temporal presence maps and enforces timeout-based eviction for online status indicators.
  • Ephemeral keys derived from geohash cells ensure privacy by preventing cross-location identity correlation while enabling presence deduplication.
  • Capacity limits defined in TransportConfig prevent unbounded memory growth in the nickname and teleported participant caches.

Frequently Asked Questions

What is the purpose of the teleported participants cache?

The teleported participants cache maintains a small set of recently seen peers who have entered the geohash cell but are not actively publishing chat messages. This allows the UI to display "recent visitors" in the peer list even when they are not currently typing, using the teleportedGeoCapacity limit to bound memory usage.

How does Bitchat ensure privacy when broadcasting location?

Bitchat uses low-precision geohashes (typically 4-5 character precision) rather than exact GPS coordinates. Each geohash cell uses a derived ephemeral key for signing presence events, ensuring that a user's identity in one cell cannot be linked to their identity in another cell or to their global Nostr identity.

What happens when the active geohash changes?

When setCurrentGeohash() receives a new geohash string, it immediately clears both the nickname map and teleported set through internal cleanup methods. This prevents stale peer data from the previous location from appearing in the new chat context, ensuring that users only see participants relevant to their current geographical cell.

How are stale presence entries removed from the store?

The GeoPresenceTracker enforces a configurable timeout threshold on the lastSeen timestamps for each public key. Entries that exceed this timeout are pruned automatically. Additionally, the UI layer can trigger manual pruning by calling retainGeoNicknames(keeping:) and retainTeleportedGeo(keeping:) with a set of currently visible public keys, evicting all others immediately.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →