feat(amethyst): in-app geohash location chat (Bitchat interop)

Adds a self-contained Android surface for Bitchat-interoperable public geohash
chat, reachable from the geohash feed screen's new chat action:

- GeohashChatScreen + GeohashChatViewModel: live subscription to the cell's
  ephemeral kind-20000/20001 events on the geographically-nearest relays, renders
  messages (nickname + teleport marker) with a live participant count, and sends
  kind-20000 messages signed with the device's per-geohash throwaway identity
  plus a small NIP-13 PoW.
- GeohashChatDeviceSeed: device-level random seed (global encrypted storage) all
  per-geohash identities derive from, kept off any account so posting never
  reveals the user's npub.
- Account.signWithAndSendPrivately; Route.GeohashChat + navigation wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
This commit is contained in:
Claude
2026-07-15 16:17:54 +00:00
parent b6729bec00
commit 5a0f3c8ff1
8 changed files with 467 additions and 1 deletions
@@ -3291,6 +3291,23 @@ class Account(
client.publish(event, relayList)
}
/**
* Sign [template] with an arbitrary [signer] (e.g. a per-geohash ephemeral
* identity that is deliberately NOT this account's key) and publish to exactly
* [relayList]. Used by geohash location chat, where authorship inside a cell
* must not be linkable to the user's npub.
*/
suspend fun <T : Event> signWithAndSendPrivately(
template: EventTemplate<T>,
signer: NostrSigner,
relayList: Set<NormalizedRelayUrl>,
): T {
val event = signer.sign(template)
cache.justConsumeMyOwnEvent(event)
if (relayList.isNotEmpty()) client.publish(event, relayList)
return event
}
suspend fun <T : Event> signAndSendPrivatelyOrBroadcast(
template: EventTemplate<T>,
relayList: (T) -> List<NormalizedRelayUrl>?,
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.geohash
import androidx.core.content.edit
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.experimental.bitchat.identity.GeohashKeyDerivation
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* Device-level random seed that all per-geohash chat identities are derived
* from (see [GeohashKeyDerivation]). Kept out of any account: geohash channel
* identities are anonymous and must not be linkable to the user's npub, and one
* seed per device (shared across accounts) matches Bitchat's model.
*
* Stored in the app's global encrypted storage, generated once on first use.
* Callers must be off the main thread (encrypted storage enforces this).
*/
object GeohashChatDeviceSeed {
private const val PREF_KEY = "geohash_chat_device_seed"
@Volatile private var cached: ByteArray? = null
fun seed(): ByteArray {
cached?.let { return it }
synchronized(this) {
cached?.let { return it }
val prefs = Amethyst.instance.encryptedStorage()
val existing = prefs.getString(PREF_KEY, null)
val seed =
if (existing != null && existing.length == GeohashKeyDerivation.SEED_SIZE * 2) {
existing.hexToByteArray()
} else {
val fresh = RandomInstance.bytes(GeohashKeyDerivation.SEED_SIZE)
prefs.edit { putString(PREF_KEY, fresh.toHexKey()) }
fresh
}
cached = seed
return seed
}
}
/** The Nostr key pair this device uses inside [geohash]. */
fun keyPair(geohash: String) = GeohashKeyDerivation.deriveKeyPair(seed(), geohash)
}
@@ -102,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarCollectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.create.NewCalendarEventScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.detail.CalendarEventDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat.GeohashChatScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.CreateGroupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.EditGroupInfoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.MarmotGroupChatScreen
@@ -625,6 +626,14 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.GeohashChat> {
GeohashChatScreen(
geohash = it.geohash,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.RelayGroup> {
RelayGroupChatScreen(
id = it.id,
@@ -641,6 +641,10 @@ sealed class Route {
@Serializable object NewEphemeralChat : Route()
@Serializable data class GeohashChat(
val geohash: String,
) : Route()
@Serializable data class RelayGroup(
val id: String,
val relayUrl: String,
@@ -0,0 +1,145 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
/**
* A Bitchat-interoperable public geohash location chat: everyone physically (or
* "teleported") in the same geohash cell shares an ephemeral, relay-broadcast
* room. Messages are signed with a per-geohash throwaway identity, so posting
* here does not reveal the account's npub.
*/
@Composable
fun GeohashChatScreen(
geohash: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: GeohashChatViewModel = viewModel(key = "GeohashChat/$geohash")
viewModel.init(geohash, accountViewModel)
val messages by viewModel.messages.collectAsStateWithLifecycle()
val participants by viewModel.participants.collectAsStateWithLifecycle()
val relays by viewModel.relays.collectAsStateWithLifecycle()
var nickname by remember { mutableStateOf("") }
var draft by remember { mutableStateOf("") }
val listState = rememberLazyListState()
Column(Modifier.fillMaxSize().imePadding()) {
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
Text("#$geohash", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
"$participants here · ${relays.size} relays",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider()
LazyColumn(
state = listState,
reverseLayout = true,
modifier = Modifier.weight(1f).fillMaxWidth(),
) {
items(messages.asReversed(), key = { it.id }) { message ->
GeohashMessageRow(message)
}
}
HorizontalDivider()
OutlinedTextField(
value = nickname,
onValueChange = { nickname = it },
singleLine = true,
label = { Text("Nickname (optional)") },
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
)
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Message #$geohash") },
)
IconButton(
enabled = draft.isNotBlank() && relays.isNotEmpty(),
onClick = {
viewModel.sendMessage(draft, nickname)
draft = ""
},
) {
SymbolIcon(symbol = MaterialSymbols.AutoMirrored.Send, contentDescription = "Send")
}
}
}
}
@Composable
private fun GeohashMessageRow(message: GeohashChatEvent) {
val name = message.nickname()?.takeIf { it.isNotBlank() } ?: message.pubKey.take(8)
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(name, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold)
if (message.isTeleported()) {
Text(
" ✈",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
Text(message.content, style = MaterialTheme.typography.bodyMedium)
}
}
@@ -0,0 +1,213 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.geohashChat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.georelay.GeoRelayCsvLoader
import com.vitorpamplona.amethyst.commons.service.georelay.GeoRelayDirectory
import com.vitorpamplona.amethyst.service.geohash.GeohashChatDeviceSeed
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashPresenceEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip13Pow.miner.PoWMiner
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Drives a single Bitchat-interoperable geohash location chat.
*
* Ephemeral kind-20000 messages (and kind-20001 presence) for the cell are
* routed to the geographically-nearest relays ([GeoRelayDirectory]); because the
* events are ephemeral the relays broadcast but do not store them, so this holds
* a live subscription for as long as the screen is open. Outgoing messages are
* signed with the device's per-geohash throwaway identity and carry a small
* NIP-13 proof of work so relays that rate-limit geohash chat let them through.
*
* Follows the codebase convention of a no-arg ViewModel initialized once via
* [init] from the composable.
*/
class GeohashChatViewModel : ViewModel() {
lateinit var geohash: String
private set
private lateinit var accountViewModel: AccountViewModel
private val _messages = MutableStateFlow<List<GeohashChatEvent>>(emptyList())
val messages: StateFlow<List<GeohashChatEvent>> = _messages.asStateFlow()
private val _relays = MutableStateFlow<List<NormalizedRelayUrl>>(emptyList())
val relays: StateFlow<List<NormalizedRelayUrl>> = _relays.asStateFlow()
private val _participants = MutableStateFlow(0)
val participants: StateFlow<Int> = _participants.asStateFlow()
private val seen = HashSet<String>()
private val present = HashSet<String>()
private val subId = newSubId()
private var started = false
private var subscribed = false
fun init(
geohash: String,
accountViewModel: AccountViewModel,
) {
if (started) return
started = true
this.geohash = geohash
this.accountViewModel = accountViewModel
viewModelScope.launch { start() }
}
private suspend fun start() {
val relays = resolveRelays()
_relays.value = relays
if (relays.isEmpty()) return
val since = TimeUtils.now() - INITIAL_LOOKBACK_SECS
val filter =
Filter(
kinds = listOf(GeohashChatEvent.KIND, GeohashPresenceEvent.KIND),
tags = mapOf("g" to listOf(geohash)),
since = since,
limit = 500,
)
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
ingest(event)
}
}
accountViewModel.account.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener)
subscribed = true
}
private fun ingest(event: Event) {
if (!event.verify()) return
if (event.geohashOrNull() != geohash) return
if (!seen.add(event.id)) return
when (event) {
is GeohashChatEvent -> {
if (present.add(event.pubKey)) _participants.value = present.size
_messages.update { current -> (current + event).sortedBy { it.createdAt } }
}
is GeohashPresenceEvent -> {
if (present.add(event.pubKey)) _participants.value = present.size
}
}
}
private fun Event.geohashOrNull(): String? =
when (this) {
is GeohashChatEvent -> geohash()
is GeohashPresenceEvent -> geohash()
else -> null
}
/** Build, mine a small PoW, sign with the per-geohash identity, and publish. */
fun sendMessage(
text: String,
nickname: String?,
teleported: Boolean = false,
) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
val relays = _relays.value.toSet()
if (relays.isEmpty()) return
viewModelScope.launch {
val keyPair = withContext(Dispatchers.IO) { GeohashChatDeviceSeed.keyPair(geohash) }
val signer = NostrSignerInternal(keyPair)
var template = GeohashChatEvent.build(trimmed, geohash, nickname = nickname?.ifBlank { null }, teleported = teleported)
template =
withContext(Dispatchers.Default) {
val deadline = System.nanoTime() + POW_TIMEOUT_NANOS
runCatching {
PoWMiner.mine(template, keyPair.pubKey.toHexKey(), POW_BITS, powThreads()) { System.nanoTime() < deadline }
}.getOrDefault(template)
}
runCatching { accountViewModel.account.signWithAndSendPrivately(template, signer, relays) }
}
}
/** Announce presence in the cell (a bare kind-20001 heartbeat). */
fun announcePresence(nickname: String?) {
val relays = _relays.value.toSet()
if (relays.isEmpty()) return
viewModelScope.launch {
val keyPair = withContext(Dispatchers.IO) { GeohashChatDeviceSeed.keyPair(geohash) }
val signer = NostrSignerInternal(keyPair)
val template = GeohashPresenceEvent.build(geohash, nickname = nickname?.ifBlank { null })
runCatching { accountViewModel.account.signWithAndSendPrivately(template, signer, relays) }
}
}
private suspend fun resolveRelays(): List<NormalizedRelayUrl> {
if (!refreshed) {
runCatching {
GeoRelayCsvLoader { Amethyst.instance.okHttpClients.getHttpClient(false) }.refresh(sharedDirectory)
}
refreshed = sharedDirectory.size > GeoRelayDirectory.FALLBACK.size
}
return sharedDirectory.closestRelays(geohash)
}
override fun onCleared() {
if (subscribed) runCatching { accountViewModel.account.client.unsubscribe(subId) }
super.onCleared()
}
companion object {
private const val INITIAL_LOOKBACK_SECS = 60L * 60L
private const val POW_BITS = 8
private const val POW_TIMEOUT_NANOS = 2_000_000_000L
private fun powThreads(): Int = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
/** Process-wide directory, refreshed once from the live CSV then reused across channels. */
private val sharedDirectory = GeoRelayDirectory()
@Volatile private var refreshed = false
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash
import android.annotation.SuppressLint
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -28,6 +29,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingGeohash
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -42,6 +44,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.dal.GeoHashFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon
@Composable
fun GeoHashScreen(
@@ -91,7 +95,7 @@ fun GeoHashScreen(
TopBarExtensibleWithBackButton(
title = {
DisplayGeoTagHeader(tag.geohash, Modifier.weight(1f))
GeoHashActionOptions(tag.geohash, accountViewModel)
GeoHashActionOptions(tag.geohash, accountViewModel, nav)
},
popBack = nav::popBack,
)
@@ -130,7 +134,15 @@ fun DisplayGeoTagHeader(
fun GeoHashActionOptions(
tag: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
IconButton(onClick = { nav.nav(Route.GeohashChat(tag)) }) {
SymbolIcon(
symbol = MaterialSymbols.AutoMirrored.Chat,
contentDescription = stringRes(R.string.geohash_chat_open),
)
}
val isFollowingTag by observeUserIsFollowingGeohash(tag, accountViewModel)
if (isFollowingTag) {
+1
View File
@@ -4198,4 +4198,5 @@
<string name="unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user">Unable to create a lightning invoice from %1$s: Element pr not found in the resulting JSON.</string>
<string name="app_name_debug" translatable="false">Amy Debug</string>
<string name="app_name_benchmark" translatable="false">Amy Benchmark</string>
<string name="geohash_chat_open">Open location chat</string>
</resources>