From 5a0f3c8ff15ecbe30f2c2e9fe6618e37b1af76da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:17:54 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV --- .../vitorpamplona/amethyst/model/Account.kt | 17 ++ .../service/geohash/GeohashChatDeviceSeed.kt | 65 ++++++ .../amethyst/ui/navigation/AppNavigation.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../chats/geohashChat/GeohashChatScreen.kt | 145 ++++++++++++ .../chats/geohashChat/GeohashChatViewModel.kt | 213 ++++++++++++++++++ .../screen/loggedIn/geohash/GeoHashScreen.kt | 14 +- amethyst/src/main/res/values/strings.xml | 1 + 8 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/geohash/GeohashChatDeviceSeed.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 25ba607e21..1a3bbc9e87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 signWithAndSendPrivately( + template: EventTemplate, + signer: NostrSigner, + relayList: Set, + ): T { + val event = signer.sign(template) + cache.justConsumeMyOwnEvent(event) + if (relayList.isNotEmpty()) client.publish(event, relayList) + return event + } + suspend fun signAndSendPrivatelyOrBroadcast( template: EventTemplate, relayList: (T) -> List?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/geohash/GeohashChatDeviceSeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/geohash/GeohashChatDeviceSeed.kt new file mode 100644 index 0000000000..7c4de58d1d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/geohash/GeohashChatDeviceSeed.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index b9b8c34ce6..835b98fc81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { + GeohashChatScreen( + geohash = it.geohash, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { RelayGroupChatScreen( id = it.id, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index b16cc4dfd9..fd99930d53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt new file mode 100644 index 0000000000..a3a1646ade --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatScreen.kt @@ -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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt new file mode 100644 index 0000000000..2466720019 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/geohashChat/GeohashChatViewModel.kt @@ -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>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _relays = MutableStateFlow>(emptyList()) + val relays: StateFlow> = _relays.asStateFlow() + + private val _participants = MutableStateFlow(0) + val participants: StateFlow = _participants.asStateFlow() + + private val seen = HashSet() + private val present = HashSet() + 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?, + ) { + 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 { + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt index 39dc2b104c..ab0299ca54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/GeoHashScreen.kt @@ -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) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f916216345..161f5bfd80 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -4198,4 +4198,5 @@ Unable to create a lightning invoice from %1$s: Element pr not found in the resulting JSON. Amy Debug Amy Benchmark + Open location chat