From 7c61eaf4e33237f17b01347386248cec7ecce5a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 22:39:59 +0000 Subject: [PATCH 1/5] feat(nests): hide rooms with no fresh speakers from drawer feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds hasFreshSpeakers gate to NestsFeedFilter so OPEN/PRIVATE rooms whose live speaker slate is empty are dropped — a room with no fresh kind-10312 presence carrying onstage=1 has effectively ended even if its kind-30312 status still says live. --- .../loggedIn/nests/dal/NestsFeedFilter.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt index b9f071612..9a3697edc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt @@ -91,6 +91,7 @@ class NestsFeedFilter( if (!hasMinimumNestFields(noteEvent)) return@filterTo false if (!isWithinPlannedWindow(noteEvent, now)) return@filterTo false if (!isLiveByPresence(noteEvent, presenceCutoff)) return@filterTo false + if (!hasFreshSpeakers(noteEvent, presenceCutoff)) return@filterTo false if (filterParams.match(noteEvent, it.relays)) return@filterTo true @@ -181,6 +182,37 @@ class NestsFeedFilter( return fresh } + /** + * Drop OPEN/PRIVATE rooms whose live speaker slate is empty. A room + * with no fresh kind-10312 presence carrying `onstage=1` has no one + * left on stage — even if the kind-30312 status still says `live`, + * there is nothing to listen to and the room has effectively ended. + * + * Same created-at grace as [isLiveByPresence] so brand-new rooms + * surface before the first speaker heartbeat arrives. CLOSED and + * PLANNED rooms bypass this gate for the same reasons listed on + * [isLiveByPresence]. + */ + private fun hasFreshSpeakers( + event: MeetingSpaceEvent, + presenceCutoff: Long, + ): Boolean { + val status = event.status() + if (status != StatusTag.STATUS.LIVE && status != StatusTag.STATUS.PRIVATE) return true + if (event.createdAt > presenceCutoff) return true + + val channel = LocalCache.getLiveActivityChannelIfExists(event.address()) ?: return false + var hasSpeaker = false + channel.notes.forEach { _, note -> + if (hasSpeaker) return@forEach + val e = note.event + if (e is MeetingRoomPresenceEvent && e.createdAt > presenceCutoff && e.onstage() == true) { + hasSpeaker = true + } + } + return hasSpeaker + } + /** * Author set used for p-tag follow expansion. Returns null for * top filters where expansion is not meaningful (mute/block, the From d87fc36d2130c83035282ad14b2fe6843105dedf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 00:02:13 +0000 Subject: [PATCH 2/5] perf(nests): index room presence separately from chat for O(speakers) scans LiveActivitiesChannel.notes is mixed-kind (chat, zaps, raids, clips, presence), and chat dominates by volume in active rooms. The Nests feed filter scanned it twice per room per recompute -- once for any-fresh-presence, once for fresh on-stage presence -- doing an `is MeetingRoomPresenceEvent` cast on every chat message just to find the speakers. Add a presenceNotes index on LiveActivitiesChannel keyed by author pubkey (presence is replaceable per author, so the key auto-collapses heartbeats). Populate it from LocalCache.consume(MeetingRoomPresenceEvent). Switch NestsFeedFilter to a single hasFreshSpeakers() pass over the index, dropping the now-redundant isLiveByPresence() check (hasFreshSpeakers implies it). Migrate NestsFeedLoaded's latest-presence flow to the same index. --- .../amethyst/model/LocalCache.kt | 5 ++ .../screen/loggedIn/nests/NestsFeedLoaded.kt | 4 +- .../loggedIn/nests/dal/NestsFeedFilter.kt | 55 +++++-------------- .../LiveActivitiesChannel.kt | 20 +++++++ 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 24e660ef4..49bf611fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1556,6 +1556,10 @@ object LocalCache : ILocalCache, ICacheProvider { * presence event needs to be in there alongside chat. Without * this, presence-driven inclusion can't see follows broadcasting * in the room (only chat-driven inclusion would fire). + * + * Also indexed under [LiveActivitiesChannel.presenceNotes] keyed + * by author so the Nests feed can answer "are there speakers on + * stage?" without scanning the chat-dominated `notes` map. */ fun consume( event: MeetingRoomPresenceEvent, @@ -1573,6 +1577,7 @@ object LocalCache : ILocalCache, ICacheProvider { val channel = getOrCreateLiveChannel(roomAddress) val versionNote = getOrCreateNote(event.id) channel.addNote(versionNote, relay) + channel.addPresenceNote(versionNote) return new } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt index 72b40ccc5..0bf6c241d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/NestsFeedLoaded.kt @@ -291,9 +291,9 @@ private fun observeRoomLatestPresence( channel .flow() .notes.stateFlow - .mapLatest { state -> + .mapLatest { _ -> var max: Long? = null - state.channel.notes.forEach { _, note -> + channel.presenceNotes.forEach { _, note -> val event = note.event if (event is MeetingRoomPresenceEvent) { val createdAt = event.createdAt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt index 9a3697edc..2564a2311 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/dal/NestsFeedFilter.kt @@ -90,7 +90,6 @@ class NestsFeedFilter( val noteEvent = it.event as? MeetingSpaceEvent ?: return@filterTo false if (!hasMinimumNestFields(noteEvent)) return@filterTo false if (!isWithinPlannedWindow(noteEvent, now)) return@filterTo false - if (!isLiveByPresence(noteEvent, presenceCutoff)) return@filterTo false if (!hasFreshSpeakers(noteEvent, presenceCutoff)) return@filterTo false if (filterParams.match(noteEvent, it.relays)) return@filterTo true @@ -151,47 +150,23 @@ class NestsFeedFilter( starts < now + PLANNED_MAX_FUTURE_SECONDS } - /** - * Drop OPEN/PRIVATE rooms that have no kind-10312 presence in the - * last [PRESENCE_FRESHNESS_WINDOW_SECONDS] AND were not created in - * the same window. The "created recently" grace lets brand-new - * rooms surface before any speaker has had time to publish their - * first heartbeat. Mirrors the NostrNests lobby gate — needs the - * lobby-wide kind-10312 REQ added in `filterNestsGlobal` to be - * meaningful. - * - * CLOSED and PLANNED rooms bypass this gate: CLOSED rooms may - * carry a recording (EGG-11), and PLANNED rooms have not started - * yet so no presence is expected. - */ - private fun isLiveByPresence( - event: MeetingSpaceEvent, - presenceCutoff: Long, - ): Boolean { - val status = event.status() - if (status != StatusTag.STATUS.LIVE && status != StatusTag.STATUS.PRIVATE) return true - if (event.createdAt > presenceCutoff) return true - - val channel = LocalCache.getLiveActivityChannelIfExists(event.address()) ?: return false - var fresh = false - channel.notes.forEach { _, note -> - if (fresh) return@forEach - val e = note.event - if (e is MeetingRoomPresenceEvent && e.createdAt > presenceCutoff) fresh = true - } - return fresh - } - /** * Drop OPEN/PRIVATE rooms whose live speaker slate is empty. A room - * with no fresh kind-10312 presence carrying `onstage=1` has no one - * left on stage — even if the kind-30312 status still says `live`, - * there is nothing to listen to and the room has effectively ended. + * with no fresh kind-10312 presence carrying `onstage=1` published + * in the last [PRESENCE_FRESHNESS_WINDOW_SECONDS] has no one left + * on stage — even if the kind-30312 status still says `live`, + * there is nothing to listen to and the room has effectively + * ended. Mirrors (and tightens) the NostrNests lobby gate. * - * Same created-at grace as [isLiveByPresence] so brand-new rooms - * surface before the first speaker heartbeat arrives. CLOSED and - * PLANNED rooms bypass this gate for the same reasons listed on - * [isLiveByPresence]. + * Brand-new rooms get a created-at grace so they surface before + * the first speaker heartbeat arrives. CLOSED rooms bypass this + * gate (they may carry a recording — EGG-11), as do PLANNED rooms + * (not started yet, no presence expected). + * + * Reads the room's [LiveActivitiesChannel.presenceNotes] index + * (keyed by author, populated by + * `LocalCache.consume(MeetingRoomPresenceEvent)`) so the scan is + * O(speakers) instead of O(all chat + zaps + presence). */ private fun hasFreshSpeakers( event: MeetingSpaceEvent, @@ -203,7 +178,7 @@ class NestsFeedFilter( val channel = LocalCache.getLiveActivityChannelIfExists(event.address()) ?: return false var hasSpeaker = false - channel.notes.forEach { _, note -> + channel.presenceNotes.forEach { _, note -> if (hasSpeaker) return@forEach val e = note.event if (e is MeetingRoomPresenceEvent && e.createdAt > presenceCutoff && e.onstage() == true) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt index 6a49058da..fc982ddf9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt @@ -26,9 +26,11 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.util.toShortDisplay import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.utils.cache.LargeCache @Stable class LiveActivitiesChannel( @@ -40,6 +42,24 @@ class LiveActivitiesChannel( // Important to keep this long-term reference because LocalCache uses WeakReferences. var infoNote: Note? = null + /** + * Audio-room presence index (NIP-53 kind-10312) keyed by author + * pubkey. Presence is replaceable (one per author), so keying on + * the author auto-collapses heartbeat versions instead of growing + * unbounded the way `notes` does. Empty for streaming channels + * (kind-30311) — only kind-30312 rooms publish presence. + * + * Lets feeds answer "is anyone live on stage in this room?" + * without scanning the chat-dominated `notes` map. See + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter]. + */ + val presenceNotes = LargeCache() + + fun addPresenceNote(note: Note) { + val author = note.author?.pubkeyHex ?: return + presenceNotes.put(author, note) + } + fun address() = address override fun relays() = info?.allRelayUrls()?.toSet()?.ifEmpty { null } ?: super.relays() From 7845072f20322c7da82358b10f313d1494c9515e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 00:56:05 +0000 Subject: [PATCH 3/5] fix(nests): evict author from prior room when presence moves to a new room kind-10312 is replaceable per author, but the room a presence points to (`a`-tag) can change when a speaker hops between rooms. The replaceable cache only swaps the addressable's content -- it doesn't know which channel the old version was attached to, so the prior room kept the stale entry in both `notes` and the new `presenceNotes` index. NestsFeedFilter would then falsely surface the prior room as "live" via that author until the entry dropped out of the freshness window. Capture the prior room from the existing addressable before consumeBaseReplaceable swaps it. When the new event is a true replacement (createdAt > prior) and the room differs, drop the author from the prior channel's presenceNotes and remove the prior version note from its main notes index. --- .../amethyst/model/LocalCache.kt | 23 +++++++++++++++++++ .../LiveActivitiesChannel.kt | 11 +++++++++ 2 files changed, 34 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 49bf611fd..10a748f07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1560,12 +1560,27 @@ object LocalCache : ILocalCache, ICacheProvider { * Also indexed under [LiveActivitiesChannel.presenceNotes] keyed * by author so the Nests feed can answer "are there speakers on * stage?" without scanning the chat-dominated `notes` map. + * + * Cross-room move handling: kind-10312 is replaceable per author, + * but the room a presence points to (`a`-tag) can change when a + * speaker hops between rooms. The replaceable cache only swaps the + * addressable's content — it has no notion of which channel the + * old version was attached to. Without explicit eviction the old + * room would keep surfacing as "live" via the stale entry until it + * dropped out of the freshness window. Capture the prior room + * before replacement and, when it differs, drop the author from + * the old channel's presence index and the old version note from + * its main `notes` index. */ fun consume( event: MeetingRoomPresenceEvent, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { + val priorVersion = getAddressableNoteIfExists(event.address())?.event as? MeetingRoomPresenceEvent + val priorRoomAddress = priorVersion?.interactiveRoom()?.address + val isReplacement = priorVersion != null && event.createdAt > priorVersion.createdAt + val new = consumeBaseReplaceable(event, relay, wasVerified) // The replaceable cache keys this on the AUTHOR's address @@ -1574,6 +1589,14 @@ object LocalCache : ILocalCache, ICacheProvider { // note to the room's channel keyed by its kind-30312 address. val roomAddress = event.interactiveRoom()?.address ?: return new if (roomAddress.kind != MeetingSpaceEvent.KIND) return new + + if (isReplacement && priorRoomAddress != null && priorRoomAddress != roomAddress) { + getLiveActivityChannelIfExists(priorRoomAddress)?.let { priorChannel -> + priorChannel.removePresenceNote(event.pubKey) + getNoteIfExists(priorVersion.id)?.let { priorChannel.removeNote(it) } + } + } + val channel = getOrCreateLiveChannel(roomAddress) val versionNote = getOrCreateNote(event.id) channel.addNote(versionNote, relay) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt index fc982ddf9..1857bcffc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt @@ -60,6 +60,17 @@ class LiveActivitiesChannel( presenceNotes.put(author, note) } + /** + * Drop an author's presence entry. Called by `LocalCache` when a + * replaceable kind-10312 from this author lands in a *different* + * room — without this eviction, the old room would keep surfacing + * as "live" via stale presence until it drops out of the + * freshness window. + */ + fun removePresenceNote(author: HexKey) { + presenceNotes.remove(author) + } + fun address() = address override fun relays() = info?.allRelayUrls()?.toSet()?.ifEmpty { null } ?: super.relays() From 566133750b23206513530bb823b11e654fc659c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 13:19:20 +0000 Subject: [PATCH 4/5] refactor(nests): keep room presence out of channel.notes entirely Presence (kind-10312) was being stored in both `channel.notes` and the `presenceNotes` index. The mixed-kind `notes` map is dominated by chat in active rooms, and only HomeLiveFilter still read presence from it -- which is now migrated to scan presenceNotes directly. - LocalCache.consume(MeetingRoomPresenceEvent): drop the `channel.addNote` call; only addPresenceNote, plus addRelay so the channel's relay-counter still tracks where presence arrived from. - LiveActivitiesChannel: addPresenceNote / removePresenceNote emit on flowSet.notes so reactive observers (NestsFeedLoaded) still update. - HomeLiveFilter.shouldIncludeChannel: scan presenceNotes separately for follow-broadcast detection in audio rooms (chat scan unchanged). - HomeLiveFilter.followsThatParticipateOn: also count presenceNotes authors so audio-room hosts/speakers factor into the participation sort even when they haven't chatted. - ChannelFeedFilter: delete the isChatEvent workaround that was excluding presence from the chat feed -- presence no longer lands there. --- .../amethyst/model/LocalCache.kt | 43 +++++++------------ .../publicChannels/dal/ChannelFeedFilter.kt | 18 +------- .../loggedIn/home/dal/HomeLiveFilter.kt | 22 ++++++++++ .../LiveActivitiesChannel.kt | 15 +++++-- 4 files changed, 52 insertions(+), 46 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 10a748f07..ae6d013ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1549,28 +1549,24 @@ object LocalCache : ILocalCache, ICacheProvider { } /** - * Audio-room presence (kind-10312) — addressable storage AND - * attach the version note to the room's [LiveActivitiesChannel]. - * The home live-bubble surfaces a room when a follow is - * publishing in it; that fan-out walks `channel.notes`, so the - * presence event needs to be in there alongside chat. Without - * this, presence-driven inclusion can't see follows broadcasting - * in the room (only chat-driven inclusion would fire). + * Audio-room presence (kind-10312) — addressable storage plus an + * author-keyed entry in the room's + * [LiveActivitiesChannel.presenceNotes] index. * - * Also indexed under [LiveActivitiesChannel.presenceNotes] keyed - * by author so the Nests feed can answer "are there speakers on - * stage?" without scanning the chat-dominated `notes` map. + * Presence is intentionally NOT added to `channel.notes`: that + * map is dominated by chat in active rooms and feeds that care + * about presence (Nests drawer, home live-bubble, NestsFeedLoaded) + * iterate `presenceNotes` directly for an O(speakers) scan. * * Cross-room move handling: kind-10312 is replaceable per author, * but the room a presence points to (`a`-tag) can change when a - * speaker hops between rooms. The replaceable cache only swaps the - * addressable's content — it has no notion of which channel the - * old version was attached to. Without explicit eviction the old - * room would keep surfacing as "live" via the stale entry until it - * dropped out of the freshness window. Capture the prior room - * before replacement and, when it differs, drop the author from - * the old channel's presence index and the old version note from - * its main `notes` index. + * speaker hops between rooms. The replaceable cache only swaps + * the addressable's content — it has no notion of which channel + * the old version was attached to. Without explicit eviction the + * old room would keep surfacing as "live" via the stale entry + * until it dropped out of the freshness window. Capture the prior + * room before replacement and, when it differs, drop the author + * from the old channel's presence index. */ fun consume( event: MeetingRoomPresenceEvent, @@ -1583,24 +1579,17 @@ object LocalCache : ILocalCache, ICacheProvider { val new = consumeBaseReplaceable(event, relay, wasVerified) - // The replaceable cache keys this on the AUTHOR's address - // (kind=10312, pubkey, fixed-d-tag) — independent of the - // room. To wire the room bubble we also attach the version - // note to the room's channel keyed by its kind-30312 address. val roomAddress = event.interactiveRoom()?.address ?: return new if (roomAddress.kind != MeetingSpaceEvent.KIND) return new if (isReplacement && priorRoomAddress != null && priorRoomAddress != roomAddress) { - getLiveActivityChannelIfExists(priorRoomAddress)?.let { priorChannel -> - priorChannel.removePresenceNote(event.pubKey) - getNoteIfExists(priorVersion.id)?.let { priorChannel.removeNote(it) } - } + getLiveActivityChannelIfExists(priorRoomAddress)?.removePresenceNote(event.pubKey) } val channel = getOrCreateLiveChannel(roomAddress) val versionNote = getOrCreateNote(event.id) - channel.addNote(versionNote, relay) channel.addPresenceNote(versionNote) + if (relay != null) channel.addRelay(relay) return new } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt index 485b97765..fea85e90b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedFilter.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent class ChannelFeedFilter( val channel: Channel, @@ -38,25 +37,12 @@ class ChannelFeedFilter( override fun changesFlow() = channel.changesFlow() // returns the last Note of each user. - override fun feed(): List = sort(channel.notes.filterIntoSet { _, it -> isChatEvent(it) && account.isAcceptable(it) }) + override fun feed(): List = sort(channel.notes.filterIntoSet { _, it -> account.isAcceptable(it) }) override fun applyFilter(newItems: Set): Set = newItems - .filter { channel.notes.containsKey(it.idHex) && isChatEvent(it) && account.isAcceptable(it) } + .filter { channel.notes.containsKey(it.idHex) && account.isAcceptable(it) } .toSet() override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) - - /** - * Reject non-chat events that get attached to a [Channel] for other - * surfaces. The current case: kind-10312 - * [MeetingRoomPresenceEvent]s land in `channel.notes` so the home - * live-bubble can detect a follow broadcasting in a Nest - * (HomeLiveFilter scans channel.notes); they have no chat content - * and would otherwise render as an empty card in the chat panel. - * - * Anything else is passed through — chat messages, zaps, raids, - * clips, channel-create / metadata events all belong here. - */ - private fun isChatEvent(note: Note): Boolean = note.event !is MeetingRoomPresenceEvent } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt index 491189e5a..29d067e40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeLiveFilter.kt @@ -103,6 +103,16 @@ class HomeLiveFilter( return true } + // Audio-room presence (kind-10312) lives in `presenceNotes`, + // not `notes`. Check it separately so a follow broadcasting in + // a Nest still surfaces the bubble even if no chat happened. + val hasPresence = + channel.presenceNotes + .filter { _, note -> + acceptableChatEvent(note, filterParams, timeLimit) + }.isNotEmpty() + if (hasPresence) return true + return channel.notes .filter { _, value -> acceptableChatEvent(value, filterParams, timeLimit) @@ -284,6 +294,18 @@ class HomeLiveFilter( } } + // Audio-room presence is indexed separately from `notes`. Add + // its author count so audio-room hosts/speakers still factor + // into the follow-participation sort even when they haven't + // chatted in the room. + if (channel is LiveActivitiesChannel) { + channel.presenceNotes.forEach { authorHex, _ -> + if (followingSet == null || authorHex in followingSet) { + count++ + } + } + } + return count } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt index 1857bcffc..75b202dae 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt @@ -49,15 +49,22 @@ class LiveActivitiesChannel( * unbounded the way `notes` does. Empty for streaming channels * (kind-30311) — only kind-30312 rooms publish presence. * - * Lets feeds answer "is anyone live on stage in this room?" - * without scanning the chat-dominated `notes` map. See - * [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter]. + * Presence lives ONLY here, not in the base `notes` map: the + * mixed-kind `notes` is dominated by chat in active rooms and + * iterating it just to find presence is wasteful. Feeds that need + * "is anyone live on stage in this room?" iterate this index + * directly. See + * [com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.dal.NestsFeedFilter] + * and [com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter]. */ val presenceNotes = LargeCache() fun addPresenceNote(note: Note) { val author = note.author?.pubkeyHex ?: return + val previous = presenceNotes.get(author) + if (previous?.idHex == note.idHex) return presenceNotes.put(author, note) + flowSet?.notes?.invalidateData() } /** @@ -68,7 +75,9 @@ class LiveActivitiesChannel( * freshness window. */ fun removePresenceNote(author: HexKey) { + if (!presenceNotes.containsKey(author)) return presenceNotes.remove(author) + flowSet?.notes?.invalidateData() } fun address() = address From 612f2aa31efdf64acd8651a0cd0279a3e0ac4ebe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 14:09:30 +0000 Subject: [PATCH 5/5] fix(nests): prune stale presence entries from LiveActivitiesChannel Presence entries are valid for ~10 min (PRESENCE_FRESHNESS_WINDOW_SECONDS in NestsFeedFilter). Without explicit cleanup, presenceNotes would grow unbounded: every author who ever heartbeats in a room leaves an entry there forever. The freshness check kept the filter result correct, but memory only ever grew. Add LiveActivitiesChannel.pruneStalePresence(cutoff) and call it from LocalCache.pruneOldMessagesChannel alongside the existing notes prune. Cutoff is 2x the freshness window (20 min) so a presence still inside any feed's window can never be reaped. --- .../amethyst/model/LocalCache.kt | 13 ++++++++++++ .../LiveActivitiesChannel.kt | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index ae6d013ef..9dee631e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2241,6 +2241,11 @@ object LocalCache : ILocalCache, ICacheProvider { } } + // 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by + // `NestsFeedFilter` so a presence still inside any feed's window + // can never be pruned. + private val PRESENCE_PRUNE_AGE_SECONDS = 20L * 60L + fun pruneOldMessagesChannel(channel: Channel) { val toBeRemoved = channel.pruneOldMessages() @@ -2254,6 +2259,14 @@ object LocalCache : ILocalCache, ICacheProvider { removeFromCache(childrenToBeRemoved) + // Audio-room presence is keyed separately from `notes` and + // never gets reaped by the top-N rule. Drop entries older + // than 2× the 10-min freshness window so the index doesn't + // grow unbounded with every author who ever heartbeat here. + if (channel is LiveActivitiesChannel) { + channel.pruneStalePresence(TimeUtils.now() - PRESENCE_PRUNE_AGE_SECONDS) + } + if (toBeRemoved.size > 100 || channel.notes.size() > 100) { println( "PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept", diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt index 75b202dae..1638b54d6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip53LiveActivities/LiveActivitiesChannel.kt @@ -80,6 +80,27 @@ class LiveActivitiesChannel( flowSet?.notes?.invalidateData() } + /** + * Drop presence entries older than [cutoff]. Without this, every + * author who ever heartbeats in this room would leave an entry + * here forever, even though the freshness check in + * `NestsFeedFilter` already treats anything past + * `PRESENCE_FRESHNESS_WINDOW_SECONDS` (10 min) as dead. Run on the + * same schedule as [pruneOldMessages] with a generous cutoff (2× + * the freshness window) so a presence still inside any feed's + * window can never be pruned. + */ + fun pruneStalePresence(cutoff: Long): Int { + val toRemove = mutableListOf() + presenceNotes.forEach { author, note -> + val createdAt = note.event?.createdAt + if (createdAt == null || createdAt < cutoff) toRemove.add(author) + } + toRemove.forEach { presenceNotes.remove(it) } + if (toRemove.isNotEmpty()) flowSet?.notes?.invalidateData() + return toRemove.size + } + fun address() = address override fun relays() = info?.allRelayUrls()?.toSet()?.ifEmpty { null } ?: super.relays()