mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:25:19 +00:00
Implement Geohash Presence (Heartbeats) (#576)
* geohash announce * only for some geohashes * global presence right away * jitter delays * show ? people for high-precision geohashes * 1000 events
This commit is contained in:
@@ -46,15 +46,17 @@ class GeohashMessageHandler(
|
|||||||
fun onEvent(event: NostrEvent, subscribedGeohash: String) {
|
fun onEvent(event: NostrEvent, subscribedGeohash: String) {
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
try {
|
try {
|
||||||
if (event.kind != 20000) return@launch
|
if (event.kind != NostrKind.EPHEMERAL_EVENT && event.kind != NostrKind.GEOHASH_PRESENCE) return@launch
|
||||||
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
|
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
|
||||||
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
|
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
|
||||||
if (dedupe(event.id)) return@launch
|
if (dedupe(event.id)) return@launch
|
||||||
|
|
||||||
// PoW validation (if enabled)
|
// PoW validation (if enabled) - apply to chat messages primarily
|
||||||
val pow = PoWPreferenceManager.getCurrentSettings()
|
if (event.kind == NostrKind.EPHEMERAL_EVENT) {
|
||||||
if (pow.enabled && pow.difficulty > 0) {
|
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||||
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
|
if (pow.enabled && pow.difficulty > 0) {
|
||||||
|
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blocked users check (use injected DataManager which has loaded state)
|
// Blocked users check (use injected DataManager which has loaded state)
|
||||||
@@ -62,7 +64,12 @@ class GeohashMessageHandler(
|
|||||||
|
|
||||||
// Update repository (participants, nickname, teleport)
|
// Update repository (participants, nickname, teleport)
|
||||||
// Update repository on a background-safe path; repository will post updates to LiveData
|
// Update repository on a background-safe path; repository will post updates to LiveData
|
||||||
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
|
|
||||||
|
// Update participant count (last seen) on BOTH Presence (20001) and Chat (20000) events
|
||||||
|
if (event.kind == NostrKind.GEOHASH_PRESENCE || event.kind == NostrKind.EPHEMERAL_EVENT) {
|
||||||
|
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
|
||||||
|
}
|
||||||
|
|
||||||
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) }
|
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) }
|
||||||
event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) }
|
event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) }
|
||||||
// Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr
|
// Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr
|
||||||
@@ -70,6 +77,9 @@ class GeohashMessageHandler(
|
|||||||
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey)
|
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey)
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Stop here for presence events - they don't produce chat messages
|
||||||
|
if (event.kind == NostrKind.GEOHASH_PRESENCE) return@launch
|
||||||
|
|
||||||
// Skip our own events for message emission
|
// Skip our own events for message emission
|
||||||
val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application)
|
val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application)
|
||||||
if (my.publicKeyHex.equals(event.pubkey, true)) return@launch
|
if (my.publicKeyHex.equals(event.pubkey, true)) return@launch
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ object NostrKind {
|
|||||||
const val SEAL = 13 // NIP-17 sealed event
|
const val SEAL = 13 // NIP-17 sealed event
|
||||||
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
|
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
|
||||||
const val EPHEMERAL_EVENT = 20000 // For geohash channels
|
const val EPHEMERAL_EVENT = 20000 // For geohash channels
|
||||||
|
const val GEOHASH_PRESENCE = 20001 // For geohash presence heartbeat
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ data class NostrFilter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create filter for geohash-scoped ephemeral events (kind 20000)
|
* Create filter for geohash-scoped ephemeral events (kind 20000 and 20001)
|
||||||
*/
|
*/
|
||||||
fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter {
|
fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 1000): NostrFilter {
|
||||||
return NostrFilter(
|
return NostrFilter(
|
||||||
kinds = listOf(NostrKind.EPHEMERAL_EVENT),
|
kinds = listOf(NostrKind.EPHEMERAL_EVENT, NostrKind.GEOHASH_PRESENCE),
|
||||||
since = since?.let { (it / 1000).toInt() },
|
since = since?.let { (it / 1000).toInt() },
|
||||||
tagFilters = mapOf("g" to listOf(geohash)),
|
tagFilters = mapOf("g" to listOf(geohash)),
|
||||||
limit = limit
|
limit = limit
|
||||||
|
|||||||
@@ -117,6 +117,28 @@ object NostrProtocol {
|
|||||||
|
|
||||||
return@withContext senderIdentity.signEvent(event)
|
return@withContext senderIdentity.signEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a geohash-scoped presence event (kind 20001)
|
||||||
|
* Has no content and no nickname, used for participant counting
|
||||||
|
*/
|
||||||
|
suspend fun createGeohashPresenceEvent(
|
||||||
|
geohash: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
): NostrEvent = withContext(Dispatchers.Default) {
|
||||||
|
val tags = mutableListOf<List<String>>()
|
||||||
|
tags.add(listOf("g", geohash))
|
||||||
|
|
||||||
|
val event = NostrEvent(
|
||||||
|
pubkey = senderIdentity.publicKeyHex,
|
||||||
|
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||||
|
kind = NostrKind.GEOHASH_PRESENCE,
|
||||||
|
tags = tags,
|
||||||
|
content = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
return@withContext senderIdentity.signEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a geohash-scoped ephemeral public message (kind 20000)
|
* Create a geohash-scoped ephemeral public message (kind 20000)
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
||||||
class GeohashViewModel(
|
class GeohashViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
@@ -55,6 +58,7 @@ class GeohashViewModel(
|
|||||||
private var currentGeohashSubId: String? = null
|
private var currentGeohashSubId: String? = null
|
||||||
private var currentDmSubId: String? = null
|
private var currentDmSubId: String? = null
|
||||||
private var geoTimer: Job? = null
|
private var geoTimer: Job? = null
|
||||||
|
private var globalPresenceJob: Job? = null
|
||||||
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
||||||
|
|
||||||
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
|
||||||
@@ -86,6 +90,10 @@ class GeohashViewModel(
|
|||||||
state.setIsTeleported(teleported)
|
state.setIsTeleported(teleported)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start global presence heartbeat loop
|
||||||
|
startGlobalPresenceHeartbeat()
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
|
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
|
||||||
state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
|
state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||||
@@ -93,6 +101,49 @@ class GeohashViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startGlobalPresenceHeartbeat() {
|
||||||
|
globalPresenceJob?.cancel()
|
||||||
|
globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
|
// Reactively restart heartbeat whenever available channels change
|
||||||
|
locationChannelManager?.availableChannels?.collectLatest { channels ->
|
||||||
|
// Filter for REGION (2), PROVINCE (4), CITY (5) - precision <= 5
|
||||||
|
val targetGeohashes = channels.filter { it.level.precision <= 5 }.map { it.geohash }
|
||||||
|
|
||||||
|
if (targetGeohashes.isNotEmpty()) {
|
||||||
|
// Enter heartbeat loop for this set of channels
|
||||||
|
// If channels change (e.g. user moves), collectLatest cancels this loop and starts a new one immediately
|
||||||
|
while (true) {
|
||||||
|
// Randomize loop interval (40-80s, average 60s)
|
||||||
|
val loopInterval = kotlin.random.Random.nextLong(40000L, 80000L)
|
||||||
|
var timeSpent = 0L
|
||||||
|
|
||||||
|
try {
|
||||||
|
Log.v(TAG, "💓 Broadcasting global presence to ${targetGeohashes.size} channels")
|
||||||
|
targetGeohashes.forEach { geohash ->
|
||||||
|
// Decorrelate individual broadcasts with random delay (1s-5s)
|
||||||
|
val stepDelay = kotlin.random.Random.nextLong(1000L, 10000L)
|
||||||
|
delay(stepDelay)
|
||||||
|
timeSpent += stepDelay
|
||||||
|
|
||||||
|
broadcastPresence(geohash)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Global presence heartbeat error: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait remaining time to satisfy target average cadence
|
||||||
|
val remaining = loopInterval - timeSpent
|
||||||
|
if (remaining > 0) {
|
||||||
|
delay(remaining)
|
||||||
|
} else {
|
||||||
|
delay(10000L) // Minimum guard delay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun panicReset() {
|
fun panicReset() {
|
||||||
repo.clearAll()
|
repo.clearAll()
|
||||||
GeohashAliasRegistry.clear()
|
GeohashAliasRegistry.clear()
|
||||||
@@ -102,10 +153,25 @@ class GeohashViewModel(
|
|||||||
currentDmSubId = null
|
currentDmSubId = null
|
||||||
geoTimer?.cancel()
|
geoTimer?.cancel()
|
||||||
geoTimer = null
|
geoTimer = null
|
||||||
|
globalPresenceJob?.cancel()
|
||||||
|
globalPresenceJob = null
|
||||||
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
|
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
|
||||||
initialize()
|
initialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun broadcastPresence(geohash: String) {
|
||||||
|
try {
|
||||||
|
val identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||||
|
val event = NostrProtocol.createGeohashPresenceEvent(geohash, identity)
|
||||||
|
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||||
|
// Presence is lightweight, send to geohash relays
|
||||||
|
relayManager.sendEventToGeohash(event, geohash, includeDefaults = false, nRelays = 5)
|
||||||
|
Log.v(TAG, "💓 Sent presence heartbeat for $geohash")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to send presence for $geohash: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
|
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -147,6 +213,8 @@ class GeohashViewModel(
|
|||||||
fun beginGeohashSampling(geohashes: List<String>) {
|
fun beginGeohashSampling(geohashes: List<String>) {
|
||||||
if (geohashes.isEmpty()) return
|
if (geohashes.isEmpty()) return
|
||||||
Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes")
|
Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes")
|
||||||
|
|
||||||
|
// Subscribe to events
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
geohashes.forEach { geohash ->
|
geohashes.forEach { geohash ->
|
||||||
subscriptionManager.subscribeGeohash(
|
subscriptionManager.subscribeGeohash(
|
||||||
@@ -160,7 +228,9 @@ class GeohashViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") }
|
fun endGeohashSampling() {
|
||||||
|
Log.d(TAG, "🌍 Ending geohash sampling")
|
||||||
|
}
|
||||||
fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash)
|
fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash)
|
||||||
fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex)
|
fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex)
|
||||||
|
|
||||||
@@ -238,7 +308,7 @@ class GeohashViewModel(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
||||||
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
|
// We don't update participant here anymore; presence loop handles it via Kind 20001
|
||||||
val teleported = state.isTeleported.value
|
val teleported = state.isTeleported.value
|
||||||
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
||||||
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
||||||
|
|||||||
@@ -706,7 +706,16 @@ private fun meshCount(viewModel: ChatViewModel): Int {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
|
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
|
||||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||||
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
|
||||||
|
// For high precision channels (Neighborhood, Block) where we don't broadcast presence,
|
||||||
|
// show "? people" instead of "0 people" to avoid misleading "nobody is here" indication.
|
||||||
|
val isHighPrecision = channel.level.precision > 5
|
||||||
|
val peopleText = if (isHighPrecision && participantCount == 0) {
|
||||||
|
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
|
||||||
|
} else {
|
||||||
|
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||||
|
}
|
||||||
|
|
||||||
val levelName = when (channel.level) {
|
val levelName = when (channel.level) {
|
||||||
com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes
|
com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes
|
||||||
com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block)
|
com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block)
|
||||||
@@ -721,7 +730,15 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int
|
|||||||
@Composable
|
@Composable
|
||||||
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
|
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
|
||||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||||
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
val level = levelForLength(geohash.length)
|
||||||
|
val isHighPrecision = level.precision > 5
|
||||||
|
|
||||||
|
val peopleText = if (isHighPrecision && participantCount == 0) {
|
||||||
|
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
|
||||||
|
} else {
|
||||||
|
ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||||
|
}
|
||||||
|
|
||||||
return "#$geohash [$peopleText]"
|
return "#$geohash [$peopleText]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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