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

> Discover how Bitchat's geohash-based peer discovery and ephemeral Nostr events find nearby users securely. Learn about the location presence store and its role in decentralized communication.

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: internals
- Published: 2026-08-09

---

**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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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:

```swift
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:

```swift
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:

```swift
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:

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

```

## Summary

- **LocationPresenceStore** ([`bitchat/App/LocationPresenceStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/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.