mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
@@ -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)
|
||||
}
|
||||
+11
@@ -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.
|
||||
|
||||
+43
@@ -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,
|
||||
|
||||
+58
-1
@@ -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
|
||||
|
||||
+1
@@ -47,6 +47,7 @@ class ChatroomListFilterAssembler(
|
||||
nip04History,
|
||||
FollowingPublicChatSubAssembler(client, ::allKeys),
|
||||
FollowingEphemeralChatSubAssembler(client, ::allKeys),
|
||||
FollowingGeohashChatSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
+56
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+63
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user