refactor(nests): collapse 4 per-room subs into one NestRoomFilterAssembler

The room screen used to fan out four parallel relay subscriptions
through separate ComposeSubscriptionManagers — RoomChat, RoomPresence,
RoomReactions, RoomAdminCommands — each opening its own REQ per outbox
relay. They all keyed on the same dimensions (roomATag, account) and
ran in the same place (NestActivityContent), so the multi-sub
fan-out was wire waste.

Replace with a single NestRoomFilterAssembler that issues two Filters
inside one REQ per relay:

  - {kinds: [1311, 10312, 7], "#a": [roomATag]}
      → chat, presence, reactions on the shared a-tag gate
  - {kinds: [4312], "#a": [roomATag], "#p": [localPubkey]}
      → admin commands; #p restriction stays on a separate Filter so
        it doesn't over-restrict the chat / presence / reaction kinds

Net wire change: 4 REQs per relay → 1 REQ per relay with 2 Filters,
same event coverage. Same kinds, same #a/#p semantics, same outbox
relay set.

Files removed:
  RoomChatFilterAssembler.kt
  RoomPresenceFilterAssembler.kt
  RoomPresenceFilterAssemblerSubscription.kt
  RoomReactionsFilterAssembler.kt
  RoomAdminCommandsFilterAssembler.kt

Files updated:
  NestActivityContent.kt — one NestRoomFilterAssemblerSubscription
    call replaces four. The per-kind LocalCache.observeEvents
    LaunchedEffects stay; only the wire side got collapsed.
  RelaySubscriptionsCoordinator.kt — register `nestRoom` instead of
    the four old per-room assemblers.

NestsFilterAssembler (the rooms-list level subscription) is unchanged
— different keying (per-user, per-follow-list), different lifecycle.

https://claude.ai/code/session_01RDpuki4t8StSg1CZcXnV5b
This commit is contained in:
Claude
2026-04-27 18:18:48 +00:00
parent 4796dceb9e
commit f5984e00c5
8 changed files with 182 additions and 512 deletions
@@ -45,11 +45,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagF
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler
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.NestsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.RoomAdminCommandsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.RoomChatFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.RoomPresenceFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.RoomReactionsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.pictures.datasource.PicturesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssembler
@@ -111,10 +108,7 @@ class RelaySubscriptionsCoordinator(
val publicChats = PublicChatsFilterAssembler(client)
val liveStreams = LiveStreamsFilterAssembler(client)
val nests = NestsFilterAssembler(client)
val roomPresence = RoomPresenceFilterAssembler(client)
val roomChat = RoomChatFilterAssembler(client)
val roomReactions = RoomReactionsFilterAssembler(client)
val roomAdminCommands = RoomAdminCommandsFilterAssembler(client)
val nestRoom = NestRoomFilterAssembler(client)
val longs = LongsFilterAssembler(client)
val articles = ArticlesFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
@@ -140,6 +134,7 @@ class RelaySubscriptionsCoordinator(
followPacksList,
liveStreams,
nests,
nestRoom,
longs,
articles,
badges,
@@ -0,0 +1,161 @@
/*
* 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.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent
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.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
/**
* Per-room state for every wire subscription scoped to a single
* Nest. Combines what used to be four parallel `*QueryState`s
* (chat, presence, reactions, admin commands) into one — the four
* relay REQs collapsed into a single subscription with two
* [Filter]s per relay (see [NestRoomFilterSubAssembler.updateFilter]).
*/
@Stable
class NestRoomQueryState(
val roomATag: String,
val localPubkey: String,
val account: Account,
)
/**
* Single per-room sub-assembler. Issues two [Filter]s per outbox
* relay on every key update:
*
* 1. `kinds=[1311, 10312, 7]`, `#a=[roomATag]` — the chat, presence
* and reaction streams the room consumes. Bundling them into
* one Filter is safe: a single Filter requires (kind ∈ kinds)
* AND (every tag dimension matches), and these three streams
* share the same `#a` gate.
*
* 2. `kinds=[4312]`, `#a=[roomATag]`, `#p=[localPubkey]` — admin
* commands. Stays separate from (1) because the additional
* `#p` constraint must NOT apply to the chat/presence/reaction
* streams (they don't tag the local user). A second Filter on
* the same REQ keeps them as one wire subscription.
*
* Replaces the previous fan-out of four sibling assemblers — chat,
* presence, reactions, admin commands — that each opened their own
* REQ. Net wire change: 4 REQs per relay → 1 REQ per relay with 2
* Filters, same event coverage.
*/
class NestRoomFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<NestRoomQueryState>,
) : PerUniqueIdEoseManager<NestRoomQueryState, String>(client, allKeys) {
override fun updateFilter(
key: NestRoomQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays = key.account.outboxRelays.flow.value
return relays.flatMap { relay ->
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds =
listOf(
LiveActivitiesChatMessageEvent.KIND,
MeetingRoomPresenceEvent.KIND,
ReactionEvent.KIND,
),
tags = mapOf("a" to listOf(key.roomATag)),
since = since?.get(relay)?.time,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(AdminCommandEvent.KIND),
tags = mapOf("a" to listOf(key.roomATag), "p" to listOf(key.localPubkey)),
since = since?.get(relay)?.time,
),
),
)
}
}
override fun id(key: NestRoomQueryState): String = "${key.roomATag}|${key.localPubkey}"
}
@Stable
class NestRoomFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<NestRoomQueryState>() {
private val sub = NestRoomFilterSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = sub.invalidateFilters()
override fun destroy() = sub.destroy()
}
/**
* Lifecycle-aware subscription. Stays open while the room screen is
* in the tree; closes on dispose. One call replaces the previous
* four (`RoomChatFilterAssemblerSubscription`,
* `RoomPresenceFilterAssemblerSubscription`,
* `RoomReactionsFilterAssemblerSubscription`,
* `RoomAdminCommandsFilterAssemblerSubscription`).
*/
@Composable
fun NestRoomFilterAssemblerSubscription(
roomATag: String,
localPubkey: String,
accountViewModel: AccountViewModel,
) = NestRoomFilterAssemblerSubscription(
roomATag,
localPubkey,
accountViewModel.account,
accountViewModel.dataSources().nestRoom,
)
@Composable
fun NestRoomFilterAssemblerSubscription(
roomATag: String,
localPubkey: String,
account: Account,
filterAssembler: NestRoomFilterAssembler,
) {
val state =
remember(roomATag, localPubkey) {
NestRoomQueryState(roomATag, localPubkey, account)
}
KeyDataSourceSubscription(state, filterAssembler)
}
@@ -1,109 +0,0 @@
/*
* 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.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent
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
/**
* Per-screen state for the per-room kind-4312 admin command sub.
* Filtered by both `#a` (the room) and `#p` (the local pubkey) so
* the relay only sends commands actually targeting the local user.
* Authority enforcement happens client-side in the room screen.
*/
@Stable
class RoomAdminCommandsQueryState(
val roomATag: String,
val localPubkey: String,
val account: Account,
)
class RoomAdminCommandsFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<RoomAdminCommandsQueryState>,
) : PerUniqueIdEoseManager<RoomAdminCommandsQueryState, String>(client, allKeys) {
override fun updateFilter(
key: RoomAdminCommandsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays = key.account.outboxRelays.flow.value
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(AdminCommandEvent.KIND),
tags = mapOf("a" to listOf(key.roomATag), "p" to listOf(key.localPubkey)),
since = since?.get(relay)?.time,
),
)
}
}
override fun id(key: RoomAdminCommandsQueryState): String = "${key.roomATag}|${key.localPubkey}"
}
@Stable
class RoomAdminCommandsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RoomAdminCommandsQueryState>() {
private val sub = RoomAdminCommandsFilterSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = sub.invalidateFilters()
override fun destroy() = sub.destroy()
}
@Composable
fun RoomAdminCommandsFilterAssemblerSubscription(
roomATag: String,
localPubkey: String,
accountViewModel: AccountViewModel,
) = RoomAdminCommandsFilterAssemblerSubscription(
roomATag,
localPubkey,
accountViewModel.account,
accountViewModel.dataSources().roomAdminCommands,
)
@Composable
fun RoomAdminCommandsFilterAssemblerSubscription(
roomATag: String,
localPubkey: String,
account: Account,
filterAssembler: RoomAdminCommandsFilterAssembler,
) {
val state = remember(roomATag, localPubkey) { RoomAdminCommandsQueryState(roomATag, localPubkey, account) }
KeyDataSourceSubscription(state, filterAssembler)
}
@@ -1,114 +0,0 @@
/*
* 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.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.chat.LiveActivitiesChatMessageEvent
/**
* Per-screen state for the per-room kind-1311 live-chat
* subscription. Mirror of [RoomPresenceQueryState] — the room's
* `a`-tag is the address-pointer string (`30312:<host>:<roomId>`).
*/
@Stable
class RoomChatQueryState(
val roomATag: String,
val account: Account,
)
/**
* REQs `kinds=[1311], #a=[roomATag]` against the user's outbox
* relays so the chat panel sees every message tagged for this
* room. Events arrive in [com.vitorpamplona.amethyst.model.LocalCache];
* the room screen pipes them into `NestViewModel.onChatEvent`.
*/
class RoomChatFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<RoomChatQueryState>,
) : PerUniqueIdEoseManager<RoomChatQueryState, String>(client, allKeys) {
override fun updateFilter(
key: RoomChatQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays = key.account.outboxRelays.flow.value
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(LiveActivitiesChatMessageEvent.KIND),
tags = mapOf("a" to listOf(key.roomATag)),
since = since?.get(relay)?.time,
),
)
}
}
override fun id(key: RoomChatQueryState): String = key.roomATag
}
@Stable
class RoomChatFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RoomChatQueryState>() {
private val sub = RoomChatFilterSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = sub.invalidateFilters()
override fun destroy() = sub.destroy()
}
/**
* Lifecycle-aware Composable subscription. Sub stays open while the
* Composable is in the tree, then closes on dispose.
*/
@Composable
fun RoomChatFilterAssemblerSubscription(
roomATag: String,
accountViewModel: AccountViewModel,
) = RoomChatFilterAssemblerSubscription(
roomATag,
accountViewModel.account,
accountViewModel.dataSources().roomChat,
)
@Composable
fun RoomChatFilterAssemblerSubscription(
roomATag: String,
account: Account,
filterAssembler: RoomChatFilterAssembler,
) {
val state = remember(roomATag) { RoomChatQueryState(roomATag, account) }
KeyDataSourceSubscription(state, filterAssembler)
}
@@ -1,95 +0,0 @@
/*
* 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.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
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.filters.Filter
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
/**
* Per-screen state for the room-presence subscription. The room
* `a`-tag is the address-pointer string (e.g.
* `30312:<host-pubkey>:<roomId>`); the relay subscription returns
* every kind-10312 event tagged with that `#a`.
*/
@Stable
class RoomPresenceQueryState(
val roomATag: String,
val account: Account,
)
/**
* REQs `kinds=[10312], #a=[roomATag]` against every relay the user
* already talks to. The events arrive in [com.vitorpamplona.amethyst.model.LocalCache]
* via the normal client pipeline; the room screen observes them
* with `LocalCache.observeEvents(...)` and feeds each event into
* `NestViewModel.onPresenceEvent`.
*
* One assembler instance services every audio-room screen — the
* `key` (= room a-tag) keeps overlapping screens (in unlikely
* scenarios such as PiP + opened-from-deep-link) cleanly separated.
*/
class RoomPresenceFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<RoomPresenceQueryState>,
) : PerUniqueIdEoseManager<RoomPresenceQueryState, String>(client, allKeys) {
override fun updateFilter(
key: RoomPresenceQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// Use the user's outbox relay set; that's the same fan-out the
// global rooms-list subscription rides on, so the presence
// events arrive on the same connections as the room itself.
val relays = key.account.outboxRelays.flow.value
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(MeetingRoomPresenceEvent.KIND),
tags = mapOf("a" to listOf(key.roomATag)),
since = since?.get(relay)?.time,
),
)
}
}
override fun id(key: RoomPresenceQueryState): String = key.roomATag
}
@Stable
class RoomPresenceFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RoomPresenceQueryState>() {
private val sub = RoomPresenceFilterSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = sub.invalidateFilters()
override fun destroy() = sub.destroy()
}
@@ -1,53 +0,0 @@
/*
* 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.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Lifecycle-aware subscription for the per-room kind-10312 presence
* stream. Mirror of [ThreadFilterAssemblerSubscription] — call this
* from the room screen and the relay sub stays open while the
* Composable is in the tree, then closes on dispose.
*/
@Composable
fun RoomPresenceFilterAssemblerSubscription(
roomATag: String,
accountViewModel: AccountViewModel,
) = RoomPresenceFilterAssemblerSubscription(
roomATag,
accountViewModel.account,
accountViewModel.dataSources().roomPresence,
)
@Composable
fun RoomPresenceFilterAssemblerSubscription(
roomATag: String,
account: Account,
filterAssembler: RoomPresenceFilterAssembler,
) {
val state = remember(roomATag) { RoomPresenceQueryState(roomATag, account) }
KeyDataSourceSubscription(state, filterAssembler)
}
@@ -1,113 +0,0 @@
/*
* 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.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.nip25Reactions.ReactionEvent
/**
* Per-screen state for the per-room kind-7 reaction subscription.
* Mirror of [RoomChatQueryState] / [RoomPresenceQueryState].
*/
@Stable
class RoomReactionsQueryState(
val roomATag: String,
val account: Account,
)
/**
* REQs `kinds=[7], #a=[roomATag]` the room's reaction stream the
* floating-overlay UI consumes. Kind 9735 (zap receipts) is not yet
* in the filter; the v1 overlay only renders kind-7 reactions.
* Adding zaps later is a one-line filter change.
*/
class RoomReactionsFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<RoomReactionsQueryState>,
) : PerUniqueIdEoseManager<RoomReactionsQueryState, String>(client, allKeys) {
override fun updateFilter(
key: RoomReactionsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays = key.account.outboxRelays.flow.value
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(ReactionEvent.KIND),
tags = mapOf("a" to listOf(key.roomATag)),
since = since?.get(relay)?.time,
),
)
}
}
override fun id(key: RoomReactionsQueryState): String = key.roomATag
}
@Stable
class RoomReactionsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RoomReactionsQueryState>() {
private val sub = RoomReactionsFilterSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = sub.invalidateFilters()
override fun destroy() = sub.destroy()
}
/**
* Lifecycle-aware Composable subscription. Sub stays open while the
* Composable is in the tree, then closes on dispose.
*/
@Composable
fun RoomReactionsFilterAssemblerSubscription(
roomATag: String,
accountViewModel: AccountViewModel,
) = RoomReactionsFilterAssemblerSubscription(
roomATag,
accountViewModel.account,
accountViewModel.dataSources().roomReactions,
)
@Composable
fun RoomReactionsFilterAssemblerSubscription(
roomATag: String,
account: Account,
filterAssembler: RoomReactionsFilterAssembler,
) {
val state = remember(roomATag) { RoomReactionsQueryState(roomATag, account) }
KeyDataSourceSubscription(state, filterAssembler)
}
@@ -39,10 +39,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.nests.NestForegroundService
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.RoomAdminCommandsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.RoomChatFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.RoomPresenceFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.RoomReactionsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssemblerSubscription
import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -145,12 +142,19 @@ private fun NestActivityBody(
LaunchedEffect(viewModel, onStageKeys) { viewModel.updateSpeakers(onStageKeys) }
// Per-room kind-10312 presence: open the wire sub for the duration
// of this Composable, observe matching events from LocalCache, feed
// them into the VM's aggregator. Drives the listener counter +
// (Tier 2) participant grid + (T1 #5) hand-raise queue.
// Single per-room wire subscription. NestRoomFilterAssembler bundles
// every #a-tagged kind we need (chat 1311, presence 10312,
// reactions 7, admin commands 4312) into one REQ per relay — see
// its docstring. Replaces the four parallel subscriptions this
// composable used to open. The LaunchedEffect collectors below
// still fan-out per-kind because each one feeds a different VM
// hook; the wire side is what got collapsed.
val roomATag = remember(event) { event.address().toValue() }
RoomPresenceFilterAssemblerSubscription(roomATag, accountViewModel)
val localPubkey = accountViewModel.account.signer.pubKey
NestRoomFilterAssemblerSubscription(roomATag, localPubkey, accountViewModel)
// Per-room kind-10312 presence — drives the listener counter,
// participant grid, and hand-raise queue.
LaunchedEffect(viewModel, roomATag) {
val filter =
Filter(
@@ -171,10 +175,7 @@ private fun NestActivityBody(
}
}
// Per-room kind-1311 live chat: open the wire sub for the duration
// of this Composable, observe matching events from LocalCache, feed
// them into the VM's chat ledger.
RoomChatFilterAssemblerSubscription(roomATag, accountViewModel)
// Per-room kind-1311 live chat — feeds the VM's chat ledger.
LaunchedEffect(viewModel, roomATag) {
val filter =
Filter(
@@ -186,11 +187,10 @@ private fun NestActivityBody(
}
}
// Per-room kind-7 reactions: same shape as chat. The VM's
// sliding-window aggregator drops entries older than 30 s; the
// 1-s tick below drives the eviction so the floating-up overlay
// animation timing is one-place rather than per-component.
RoomReactionsFilterAssemblerSubscription(roomATag, accountViewModel)
// Per-room kind-7 reactions. The VM's sliding-window aggregator
// drops entries older than 30 s; the 1-s tick below drives the
// eviction so the floating-up overlay animation timing is one-
// place rather than per-component.
LaunchedEffect(viewModel, roomATag) {
val filter =
Filter(
@@ -214,8 +214,6 @@ private fun NestActivityBody(
// (not in the relay) — only honour kicks where the signer's
// ParticipantTag.canSpeak() returns true on the active
// kind-30312. nostrnests' UI does the same gating.
val localPubkey = accountViewModel.account.signer.pubKey
RoomAdminCommandsFilterAssemblerSubscription(roomATag, localPubkey, accountViewModel)
LaunchedEffect(viewModel, roomATag, localPubkey) {
val filter =
Filter(