diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index d610d409e..0859ef4b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, 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 763541666..c396b5df2 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 @@ -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 { - NestRoomFilterAssemblerSubscription(note, accountViewModel) + NestRoomLivenessProbeSubscription(note, accountViewModel) val channel = remember(note.idHex) { LocalCache.getOrCreateLiveChannel(note.address) } val flow = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt new file mode 100644 index 000000000..9c45989ea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomLivenessProbeAssembler.kt @@ -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, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: NestRoomLivenessQueryState, + since: SincePerRelayMap?, + ): List { + 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() { + 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) +}