mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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>?,
|
||||
|
||||
+65
@@ -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,
|
||||
|
||||
+145
@@ -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)
|
||||
}
|
||||
}
|
||||
+213
@@ -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
|
||||
}
|
||||
}
|
||||
+13
-1
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user