perf(nests): dedicated kinds=[10312] limit=1 feed liveness probe

Feed thumbnails were reusing NestRoomFilterAssemblerSubscription to
flip the LIVE/ENDED badge based on the freshest kind-10312 presence.
That assembler subscribes to chat + presence + reactions (kinds 1311,
10312, 7) — so a feed of N rooms paid each popular room's full
chat-history pull per outbox relay just to render a status pill.

Add NestRoomLivenessAssembler, scoped to a single kinds=[10312],
#a=[room], limit=1 filter per relay, and switch the feed thumbnail
to it. The full chat / reaction / admin command REQ stays gated
behind the join flow.

https://claude.ai/code/session_01DMeCvWyBYVVVPez2hwqCs4
This commit is contained in:
Claude
2026-05-01 19:25:03 +00:00
parent 3acd84bf76
commit 714159f816
3 changed files with 154 additions and 10 deletions
@@ -46,6 +46,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterA
import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.LiveStreamsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
@@ -109,6 +110,7 @@ class RelaySubscriptionsCoordinator(
val liveStreams = LiveStreamsFilterAssembler(client)
val nests = NestsFilterAssembler(client)
val nestRoom = NestRoomFilterAssembler(client)
val nestRoomLiveness = NestRoomLivenessAssembler(client)
val longs = LongsFilterAssembler(client)
val articles = ArticlesFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
@@ -135,6 +137,7 @@ class RelaySubscriptionsCoordinator(
liveStreams,
nests,
nestRoom,
nestRoomLiveness,
longs,
articles,
badges,
@@ -78,7 +78,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53L
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.PrivateFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip53LiveActivities.LoadParticipants
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessProbeSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
import com.vitorpamplona.amethyst.ui.stringRes
@@ -300,14 +300,14 @@ private fun RenderLiveOrEndedFromPresence(
/**
* Most recent kind-10312 presence `createdAt` cached for [address],
* or null until one arrives. Reuses the room's existing assembler
* subscription (`NestRoomFilterAssemblerSubscription`)same
* subscription that warms up when the user actually opens the room
* — and reads version notes off the room's [LocalCache] channel,
* which `LocalCache.consume(MeetingRoomPresenceEvent, …)` attaches
* by `#a=room`. Trade-off: that REQ also pulls chat + reactions, so
* a thumbnail probe pays a popular room's full message history. If
* that load matters, swap to a dedicated limit:1 assembler.
* or null until one arrives. Uses the dedicated
* [NestRoomLivenessProbeSubscription]`kinds=[10312], #a=[roomA],
* limit=1` per outbox relay — so a feed of N rooms doesn't pay
* each popular room's chat-history pull just to render a LIVE /
* ENDED badge. The assembler reads version notes off the room's
* [LocalCache] channel, which `LocalCache.consume(MeetingRoomPresenceEvent, …)`
* attaches by `#a=room`. The full chat / reaction / admin REQs
* stay gated behind the join flow (see [NestRoomFilterAssemblerSubscription]).
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
@@ -315,7 +315,7 @@ private fun observeRoomLatestPresence(
note: AddressableNote,
accountViewModel: AccountViewModel,
): State<Long?> {
NestRoomFilterAssemblerSubscription(note, accountViewModel)
NestRoomLivenessProbeSubscription(note, accountViewModel)
val channel = remember(note.idHex) { LocalCache.getOrCreateLiveChannel(note.address) }
val flow =
@@ -0,0 +1,141 @@
/*
* 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.nests.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
/**
* Per-room state for the lightweight liveness probe — used by feed
* thumbnail cards to flip the LIVE / ENDED badge based on whether
* any speaker has published a fresh kind-10312 presence.
*
* Distinct from [NestRoomQueryState] (which fans out to chat,
* presence, reactions, and admin commands for an actively-joined
* room) — the feed should not pay a popular room's full chat
* history just to render a status pill.
*/
@Stable
class NestRoomLivenessQueryState(
val note: AddressableNote,
val account: Account,
)
/**
* Single per-room sub-assembler for the feed thumbnail liveness
* probe. Issues ONE narrow [Filter] per outbox relay:
*
* `kinds=[10312]`, `#a=[roomATag]`, `limit=1`
*
* Net wire change: the previous code reused [NestRoomFilterSubAssembler]
* for the badge probe, which subscribes to chat + presence + reactions
* (kinds 1311, 10312, 7) — so a feed of N rooms cost N popular-room
* chat-history pulls per relay. This probe asks for the single
* latest presence per room and nothing else.
*/
class NestRoomLivenessSubAssembler(
client: INostrClient,
allKeys: () -> Set<NestRoomLivenessQueryState>,
) : PerUniqueIdEoseManager<NestRoomLivenessQueryState, String>(client, allKeys) {
override fun updateFilter(
key: NestRoomLivenessQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays =
key.account.outboxRelays.flow.value +
key.note.relays +
((key.note.event as? MeetingSpaceEvent)?.relays()?.toSet() ?: emptySet())
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(MeetingRoomPresenceEvent.KIND),
tags = mapOf("a" to listOf(key.note.idHex)),
// limit=1: the badge only needs the freshest
// heartbeat — older ones don't change the
// verdict. since is still threaded so the
// EOSE manager can resume after a reconnect.
limit = 1,
since = since?.get(relay)?.time,
),
)
}
}
override fun id(key: NestRoomLivenessQueryState): String = key.note.idHex
}
@Stable
class NestRoomLivenessAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<NestRoomLivenessQueryState>() {
private val sub = NestRoomLivenessSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = sub.invalidateFilters()
override fun destroy() = sub.destroy()
}
/**
* Lifecycle-aware subscription. Stays open while the feed card is in
* the tree (LazyColumn auto-disposes off-screen cards); closes on
* dispose. Use this from feed thumbnails — the join flow uses
* [NestRoomFilterAssemblerSubscription] which is heavier.
*/
@Composable
fun NestRoomLivenessProbeSubscription(
note: AddressableNote,
accountViewModel: AccountViewModel,
) = NestRoomLivenessProbeSubscription(
note,
accountViewModel.account,
accountViewModel.dataSources().nestRoomLiveness,
)
@Composable
fun NestRoomLivenessProbeSubscription(
note: AddressableNote,
account: Account,
filterAssembler: NestRoomLivenessAssembler,
) {
val state =
remember(note, account.pubKey) {
NestRoomLivenessQueryState(note, account)
}
KeyDataSourceSubscription(state, filterAssembler)
}