feat(geohash-chat): native Messages rooms via LocalCache (Phase A+B)

Makes geohash location channels first-class in the Messages tab by routing them
through the same LocalCache -> feed machinery every other room uses:

Phase A (foundation):
- commons GeohashChatChannel : Channel, keyed by the bare geohash, with a
  placeholder-note so a just-joined cell shows before its first message.
- LocalCache: geohashChannels map, get/getOrCreateGeohashChannel, a
  consume(GeohashChatEvent) that routes kind-20000 messages into the cell's
  channel (presence 20001 stays with the live screen), plus getAnyChannel + the
  prune loops.
- GeohashRelays: a process-wide geohash->relay directory (live CSV once, fallback
  otherwise). FollowingGeohashChatSubAssembler + filterFollowingGeohashChats
  subscribe the joined cells (account.geohashList) to each cell's nearest relays,
  registered in ChatroomListFilterAssembler.

Phase B (Messages):
- ChatroomListKnownFeedFilter: a geohashChannels family (feed + incremental
  updateListWith/applyFilter + filterRelevantGeohashChats + geohashRowKey so a
  placeholder and its later real message resolve to one row).
- ChatroomHeaderCompose: a GeohashRoomCompose row (location pin, anonymous —
  name from the message's n tag) -> Route.GeohashChat.
- AccountFeedContentStates: rebuild the list when the joined set changes.

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-16 01:07:48 +00:00
parent fba41ecd8e
commit 6657957677
10 changed files with 416 additions and 1 deletions
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
@@ -60,6 +61,7 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
@@ -357,6 +359,7 @@ object LocalCache : ILocalCache, ICacheProvider {
val publicChatChannels = LargeCache<HexKey, PublicChatChannel>()
val liveChatChannels = LargeCache<Address, LiveActivitiesChannel>()
val ephemeralChannels = LargeCache<RoomId, EphemeralChatChannel>()
val geohashChannels = LargeCache<String, GeohashChatChannel>()
val relayGroupChannels = LargeCache<GroupId, RelayGroupChannel>()
val concordChannels = LargeCache<ConcordChannelId, ConcordChannel>()
@@ -632,6 +635,8 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getEphemeralChatChannelIfExists(key: RoomId): EphemeralChatChannel? = ephemeralChannels.get(key)
fun getGeohashChannelIfExists(geohash: String): GeohashChatChannel? = geohashChannels.get(geohash)
fun getRelayGroupChannelIfExists(key: GroupId): RelayGroupChannel? = relayGroupChannels.get(key)
/** Every relay group we know of that is hosted on [relay] (its channel directory). */
@@ -725,6 +730,8 @@ object LocalCache : ILocalCache, ICacheProvider {
fun getOrCreateEphemeralChannel(key: RoomId): EphemeralChatChannel = ephemeralChannels.getOrCreate(key) { EphemeralChatChannel(key) }
fun getOrCreateGeohashChannel(geohash: String): GeohashChatChannel = geohashChannels.getOrCreate(geohash) { GeohashChatChannel(geohash) }
fun getOrCreateRelayGroupChannel(key: GroupId): RelayGroupChannel = relayGroupChannels.getOrCreate(key) { RelayGroupChannel(key) }
fun getConcordChannelIfExists(key: ConcordChannelId): ConcordChannel? = concordChannels.get(key)
@@ -1478,6 +1485,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is LiveActivitiesChatMessageEvent -> noteEvent.activityAddress()?.let { getLiveActivityChannelIfExists(it) }
is LiveActivitiesEvent -> getLiveActivityChannelIfExists(noteEvent.address())
is EphemeralChatEvent -> noteEvent.roomId()?.let { getEphemeralChatChannelIfExists(it) }
is GeohashChatEvent -> noteEvent.geohash()?.let { getGeohashChannelIfExists(it) }
else -> null
}
@@ -1843,6 +1851,30 @@ object LocalCache : ILocalCache, ICacheProvider {
return new
}
/**
* Public geohash chat message (kind 20000). Routes into the cell's
* [GeohashChatChannel]. Presence (kind 20001) is deliberately NOT consumed
* here — it is an empty-content heartbeat handled by the live chat screen, so
* it never becomes a room's "last message".
*/
fun consume(
event: GeohashChatEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
): Boolean {
val geohash = event.geohash() ?: return false
val new = consumeRegularEvent(event, relay, wasVerified)
if (new) {
val note = getOrCreateNote(event.id)
val channel = getOrCreateGeohashChannel(geohash)
channel.addNote(note, relay)
}
return new
}
/**
* NIP-29 relay-signed group metadata (kind 39000). Stored as an addressable
* note and used to populate the [RelayGroupChannel]'s name/picture/about/
@@ -2881,6 +2913,10 @@ object LocalCache : ILocalCache, ICacheProvider {
pruneHiddenMessagesChannel(channel, account)
}
geohashChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
liveChatChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
@@ -2934,6 +2970,10 @@ object LocalCache : ILocalCache, ICacheProvider {
pruneOldMessagesChannel(channel)
}
geohashChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
liveChatChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
@@ -3817,6 +3857,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consume(event, relay, wasVerified)
}
is GeohashChatEvent -> {
consume(event, relay, wasVerified)
}
is EphemeralChatListEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
@@ -0,0 +1,52 @@
/*
* 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 com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.georelay.GeoRelayCsvLoader
import com.vitorpamplona.amethyst.commons.service.georelay.GeoRelayDirectory
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Process-wide geohash relay directory, shared by everything that routes
* geohash chat traffic (the joined-cell subscription, the chat screen). The live
* CSV is fetched once via [ensureLoaded]; until then [closestRelays] falls back
* to the small built-in list so routing works offline / on first run.
*/
object GeohashRelays {
private val directory = GeoRelayDirectory()
@Volatile private var refreshed = false
/** Fetches the live directory once. Safe to call repeatedly; subsequent calls are no-ops. */
suspend fun ensureLoaded(): Boolean {
if (refreshed) return false
runCatching {
GeoRelayCsvLoader { Amethyst.instance.okHttpClients.getHttpClient(false) }.refresh(directory)
}
val loaded = directory.size > GeoRelayDirectory.FALLBACK.size
refreshed = loaded
return loaded
}
/** The relays nearest [geohash]'s center. Synchronous — uses whatever is loaded (fallback if not yet refreshed). */
fun closestRelays(geohash: String): List<NormalizedRelayUrl> = directory.closestRelays(geohash)
}
@@ -179,6 +179,17 @@ class AccountFeedContentStates(
}
}
// Joining/leaving a geohash location channel (kind 10081 list) changes the Messages list but
// no event flows through LocalCache, so force a rebuild — otherwise a just-joined cell (whose
// ephemeral messages haven't arrived yet) wouldn't show its placeholder row until later.
scope.launch(Dispatchers.IO) {
account.geohashList.flow
.drop(1)
.collect {
dmKnown.invalidateData()
}
}
// Flipping the NIP-29 view mode (inline groups vs one row per relay) changes what the
// Messages feed emits for joined groups, but no event flows through LocalCache — force a
// full rebuild so the list switches shape immediately.
@@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupChatroom
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
@@ -99,6 +100,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.newItemBubbleModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
@@ -193,6 +195,12 @@ private fun ChatroomEntry(
return
}
val geohashChannel = lastMessage.inGatherers?.firstNotNullOfOrNull { it as? GeohashChatChannel }
if (geohashChannel != null) {
GeohashRoomCompose(lastMessage, geohashChannel, accountViewModel, nav)
return
}
// A NIP-29 group message whose channel gatherer didn't attach (e.g. loaded before its channel
// existed, or via a path that skips attach) has no case in the when() below and would blank out.
// Resolve the group from its `h` tag + provenance relay and render the group row anyway.
@@ -332,6 +340,41 @@ private fun ChannelRoomCompose(
)
}
@Composable
private fun GeohashRoomCompose(
lastMessage: Note,
channel: GeohashChatChannel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val channelState by observeChannel(channel, accountViewModel)
val geohashChannel = channelState?.channel as? GeohashChatChannel ?: channel
// Anonymous cells have no author profile; the sender's display name lives in the message's `n` tag.
val noteEvent = lastMessage.event as? GeohashChatEvent
val nick = noteEvent?.nickname()?.takeIf { it.isNotBlank() } ?: lastMessage.author?.pubkeyHex?.take(8)
val description = noteEvent?.content?.take(200)
val lastContent = if (noteEvent != null) "$nick: $description" else ""
val lastReadTime by accountViewModel.account.loadLastReadFlow("Geohash/${geohashChannel.geohash}").collectAsStateWithLifecycle()
ChannelName(
channelIdHex = "Geohash/${geohashChannel.geohash}",
channelPicture = null,
channelTitle = { modifier -> ChannelTitleWithLabelInfo(geohashChannel.toBestDisplayName(), MaterialSymbols.LocationOn, R.string.geohash_chat, modifier) },
channelLastTime = lastMessage.createdAt(),
channelLastContent = lastContent,
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif =
accountViewModel.settings.autoPlayVideosFlow
.collectAsStateWithLifecycle()
.value,
onClick = { nav.nav(Route.GeohashChat(geohashChannel.geohash)) },
)
}
@Composable
private fun MarmotGroupRoomCompose(
lastMessage: Note,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.util.replace
@@ -31,6 +32,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -91,6 +93,18 @@ class ChatroomListKnownFeedFilter(
.firstOrNull()
}
// Joined geohash location channels (kind 10081 list). Ephemeral, so a quiet
// cell has no stored message — show a placeholder row until one arrives, the
// same way just-joined NIP-29/Marmot groups do.
val geohashChannels =
account.geohashList.flow.value.map { geohash ->
val channel = LocalCache.getOrCreateGeohashChannel(geohash)
channel.notes
.filter { _, it -> account.isAcceptable(it) && it.event != null }
.sortedByDefaultFeedOrder()
.firstOrNull() ?: channel.placeholderNote()
}
val marmotGroups =
account.marmotGroupList.rooms.mapNotNull { _, chatroom ->
if (chatroom.isKnown(followingKeySet)) {
@@ -160,7 +174,7 @@ class ChatroomListKnownFeedFilter(
}
}
return sort((privateMessages + publicChannels + ephemeralChats + marmotGroups + relayGroups + concordChannels).toSet())
return sort((privateMessages + publicChannels + ephemeralChats + geohashChannels + marmotGroups + relayGroups + concordChannels).toSet())
}
override fun updateListWith(
@@ -172,6 +186,7 @@ class ChatroomListKnownFeedFilter(
// Gets the latest message by channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
val newRelevantEphemeralChats = filterRelevantEphemeralChats(newItems, account)
val newRelevantGeohashChats = filterRelevantGeohashChats(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
@@ -181,6 +196,7 @@ class ChatroomListKnownFeedFilter(
if (newRelevantPrivateMessages.isEmpty() &&
newRelevantPublicMessages.isEmpty() &&
newRelevantEphemeralChats.isEmpty() &&
newRelevantGeohashChats.isEmpty() &&
newRelevantRelayGroups.isEmpty() &&
newRelevantConcord.isEmpty()
) {
@@ -221,6 +237,21 @@ class ChatroomListKnownFeedFilter(
}
}
newRelevantGeohashChats.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
if (newNotePair.key == oldNote.geohashRowKey()) {
hasUpdated = true
if ((newNotePair.value.createdAt() ?: 0L) > (oldNote.createdAt() ?: 0L)) {
myNewList = myNewList.replace(oldNote, newNotePair.value)
}
}
}
if (!hasUpdated) {
myNewList = myNewList.plus(newNotePair.value)
}
}
newRelevantPrivateMessages.forEach { newNotePair ->
var hasUpdated = false
oldList.forEach { oldNote ->
@@ -275,6 +306,7 @@ class ChatroomListKnownFeedFilter(
// Gets the latest message by channel from the new items.
val newRelevantPublicMessages = filterRelevantPublicMessages(newItems, account)
val newRelevantEphemeralChats = filterRelevantEphemeralChats(newItems, account)
val newRelevantGeohashChats = filterRelevantGeohashChats(newItems, account)
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
@@ -284,6 +316,7 @@ class ChatroomListKnownFeedFilter(
return if (newRelevantPrivateMessages.isEmpty() &&
newRelevantPublicMessages.isEmpty() &&
newRelevantEphemeralChats.isEmpty() &&
newRelevantGeohashChats.isEmpty() &&
newRelevantRelayGroups.isEmpty() &&
newRelevantConcord.isEmpty()
) {
@@ -293,12 +326,36 @@ class ChatroomListKnownFeedFilter(
newRelevantPrivateMessages.values +
newRelevantPublicMessages.values +
newRelevantEphemeralChats.values +
newRelevantGeohashChats.values +
newRelevantRelayGroups.values +
newRelevantConcord.values
).toSet()
}
}
/** The geohash a Messages row belongs to — from a real kind-20000 note or a placeholder's channel gatherer. */
private fun Note.geohashRowKey(): String? =
(event as? GeohashChatEvent)?.geohash()
?: inGatherers?.firstNotNullOfOrNull { (it as? GeohashChatChannel)?.geohash }
private fun filterRelevantGeohashChats(
newItems: Set<Note>,
account: Account,
): MutableMap<String, Note> {
val joined = account.geohashList.flow.value
val newRelevant = mutableMapOf<String, Note>()
newItems.forEach { newNote ->
val geohash = (newNote.event as? GeohashChatEvent)?.geohash()
if (geohash != null && geohash in joined && account.isAcceptable(newNote)) {
val lastNote = newRelevant[geohash]
if (lastNote == null || (newNote.createdAt() ?: 0L) > (lastNote.createdAt() ?: 0L)) {
newRelevant[geohash] = newNote
}
}
}
return newRelevant
}
/**
* The row a Concord note belongs to, so [updateListWith] can find and replace it: a per-community
* [ConcordServerRoomNote] (GROUPED), else the note's ConcordChannel gatherer keyed by channel
@@ -47,6 +47,7 @@ class ChatroomListFilterAssembler(
nip04History,
FollowingPublicChatSubAssembler(client, ::allKeys),
FollowingEphemeralChatSubAssembler(client, ::allKeys),
FollowingGeohashChatSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
@@ -0,0 +1,56 @@
/*
* 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.rooms.datasource
import com.vitorpamplona.amethyst.service.geohash.GeohashRelays
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
/**
* REQ for the user's joined geohash location channels: for each cell, subscribe
* to its kind-20000 messages on the relays nearest that cell
* ([GeohashRelays.closestRelays]) the same rendezvous set Bitchat uses. One
* [RelayBasedFilter] per (cell, relay). Presence (kind 20001) is left to the live
* chat screen so it never becomes a room's last message.
*/
fun filterFollowingGeohashChats(
geohashes: Set<String>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
if (geohashes.isEmpty()) return null
return geohashes.flatMap { geohash ->
GeohashRelays.closestRelays(geohash).map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(GeohashChatEvent.KIND),
tags = mapOf("g" to listOf(geohash)),
limit = 100,
since = since?.get(relay)?.time,
),
)
}
}
}
@@ -0,0 +1,87 @@
/*
* 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.rooms.datasource
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.geohash.GeohashRelays
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
/**
* Keeps the user's joined geohash location channels ([account.geohashList]) live
* in [com.vitorpamplona.amethyst.model.LocalCache] so they surface in the rooms
* list and Home, mirroring [FollowingEphemeralChatSubAssembler]. Because the
* events are ephemeral, this is a live tail quiet cells simply have no last
* message until one arrives.
*/
class FollowingGeohashChatSubAssembler(
client: INostrClient,
allKeys: () -> Set<ChatroomListState>,
) : PerUserEoseManager<ChatroomListState>(client, allKeys) {
override fun updateFilter(
key: ChatroomListState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
listOfNotNull(
filterFollowingGeohashChats(key.account.geohashList.flow.value, since),
).flatten()
override fun user(key: ChatroomListState) = key.account.userProfile()
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: ChatroomListState): Subscription {
userJobMap[key.account.userProfile()]?.forEach { it.cancel() }
userJobMap[key.account.userProfile()] =
listOf(
// Rebuild when the joined set changes.
key.account.scope.launch(Dispatchers.IO) {
key.account.geohashList.flow.sample(500).collectLatest {
invalidateFilters()
}
},
// Once the live relay directory loads, re-route to the proper nearest relays.
key.account.scope.launch(Dispatchers.IO) {
if (GeohashRelays.ensureLoaded()) invalidateFilters()
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
+1
View File
@@ -4293,6 +4293,7 @@
<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>
<string name="geohash_chat">Location chat</string>
<string name="new_conversation_location_title">Location channel</string>
<string name="new_conversation_location_tagline">Chat with whoever\'s near a place, by geohash.</string>
<string name="new_conversation_location_chip">Local</string>
@@ -0,0 +1,63 @@
/*
* 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.commons.model.geohashChat
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A public geohash location chat channel (Bitchat-interoperable), keyed by the
* bare geohash cell. Unlike an ephemeral relay chat which lives on one relay
* a cell is served by the relays geographically nearest its center, so this
* channel does not carry a relay in its key; the subscription layer derives the
* relay set from the geohash.
*/
@Stable
class GeohashChatChannel(
val geohash: String,
) : Channel() {
override fun toBestDisplayName() = "#$geohash"
fun anyNameStartsWith(prefix: String): Boolean = geohash.contains(prefix, true)
private val placeholderLock = KmpLock()
private var cachedPlaceholder: Note? = null
/**
* A stable empty-row Note so a just-joined cell shows in Messages before its
* first (ephemeral) message arrives mirrors the NIP-29 / Marmot group rows.
*/
fun placeholderNote(): Note =
placeholderLock.withLock {
cachedPlaceholder ?: Note(placeholderIdHex(geohash)).apply {
addGatherer(this@GeohashChatChannel)
cachedPlaceholder = this
}
}
companion object {
fun placeholderIdHex(geohash: String): HexKey = "geohash-empty-$geohash"
}
}