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 d610d409e5..0859ef4b35 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/AccountSessionManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index ee43b4c4dd..c26f6840da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -331,6 +331,12 @@ class AccountSessionManager( accountInfo: AccountInfo, routeBuilder: ((account: Account) -> Route?)? = null, ) { + // The Nest audio-room activity reads the active AccountViewModel + // through a process-singleton bridge. Drop the previous user's + // ref before swapping so a stale ref can't survive into the new + // session — see [NestBridge]. + com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge + .clear() localPreferences.switchToAccount(accountInfo) loginWithDefaultAccount(routeBuilder) } @@ -351,6 +357,11 @@ class AccountSessionManager( fun logOff(accountInfo: AccountInfo) { scope.launch(Dispatchers.IO) { if (accountInfo.npub == currentAccountNPub()) { + // Drop the Nest bridge ref before tearing down the + // current account so the audio-room activity can't + // pick up a stale AccountViewModel — see [NestBridge]. + com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge + .clear() // log off and relogin with the 0 account localPreferences.deleteAccount(accountInfo) accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) 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 7635416662..c396b5df29 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/create/CreateNestSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt index 62d53c48e1..a189a7ce3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/create/CreateNestSheet.kt @@ -331,23 +331,25 @@ private fun ScheduleStartPicker( TextButton(onClick = { val dayMillisUtc = datePickerState.selectedDateMillis if (dayMillisUtc != null) { - // DatePicker hands back UTC midnight of the selected - // calendar date. Add the picked hour:minute (local) - // to its seconds, then subtract the local offset to - // land on the correct UTC unix second — same shape - // as ExpirationDatePicker. - val localSeconds = - dayMillisUtc / 1000L + - timePickerState.hour * 3600L + - timePickerState.minute * 60L - val offsetSec = - java.time.ZoneId - .systemDefault() - .rules - .getOffset(java.time.Instant.now()) - .totalSeconds - .toLong() - onChange(localSeconds - offsetSec) + // DatePicker hands back UTC midnight of the + // selected calendar date. Reinterpret that + // calendar date as local + the picked + // hour:minute, then convert to UTC seconds + // using the zone offset AT THE PICKED INSTANT. + // The previous code used `Instant.now()`'s + // offset, which was off by one hour for rooms + // scheduled across a DST transition. + val zone = java.time.ZoneId.systemDefault() + val localDate = + java.time.Instant + .ofEpochMilli(dayMillisUtc) + .atZone(java.time.ZoneOffset.UTC) + .toLocalDate() + val pickedZdt = + localDate + .atTime(timePickerState.hour, timePickerState.minute) + .atZone(zone) + onChange(pickedZdt.toEpochSecond()) } showTime = false }) { Text(stringRes(R.string.nest_create_submit)) } 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 0000000000..9c45989ea1 --- /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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt index 8bac896c3b..11d37caf6c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt @@ -21,6 +21,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -28,6 +37,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel @@ -46,6 +60,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.PipBri import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle.rememberNestViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen.NestFullScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.screen.NestPipScreen +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.nestsclient.NestsRoomConfig import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent @@ -71,45 +86,117 @@ internal fun NestActivityContent( ) { val parsedAddress = remember(addressValue) { Address.parse(addressValue) } if (parsedAddress == null) { - // Malformed address — bail. The Activity will receive a Closed - // signal through the VM teardown when the user finishes; until - // then we just render nothing. + // Malformed address — show the unjoinable state so the user + // gets a clear path back instead of staring at a black screen + // while the Activity sits behind a useless extra. + UnjoinableRoomState(onLeave = onLeave) return } LoadAddressableNote(parsedAddress, accountViewModel) { addressableNote -> - addressableNote ?: return@LoadAddressableNote + if (addressableNote == null) { + // Note hasn't resolved yet — relay subscription is in + // flight. Render a centered spinner so the user sees that + // we're working on it (audit Android #6). + LoadingRoomState() + return@LoadAddressableNote + } val event by observeNoteEvent(addressableNote, accountViewModel) - event?.let { - val service = it.service() - val endPoint = it.endpoint() - if (service != null && endPoint != null) { - val viewModel = - rememberNestViewModel( - NestsRoomConfig( - authBaseUrl = service, - endpoint = endPoint, - hostPubkey = it.pubKey, - roomId = it.dTag(), - kind = it.kind, - ), - accountViewModel.account.signer, - ) + val current = event + if (current == null) { + LoadingRoomState() + return@LoadAddressableNote + } + val service = current.service() + val endPoint = current.endpoint() + if (service.isNullOrBlank() || endPoint.isNullOrBlank()) { + UnjoinableRoomState(onLeave = onLeave) + return@LoadAddressableNote + } + val viewModel = + rememberNestViewModel( + NestsRoomConfig( + authBaseUrl = service, + endpoint = endPoint, + hostPubkey = current.pubKey, + roomId = current.dTag(), + kind = current.kind, + ), + accountViewModel.account.signer, + ) - NestActivityBody( - event = it, - roomNote = addressableNote, - viewModel = viewModel, - accountViewModel = accountViewModel, - isInPipMode = isInPipMode, - isPipSupported = isPipSupported, - onMuteState = onMuteState, - onConnectedChange = onConnectedChange, - pipMuteSignal = pipMuteSignal, - onLeave = onLeave, - onMinimize = onMinimize, - ) + NestActivityBody( + event = current, + roomNote = addressableNote, + viewModel = viewModel, + accountViewModel = accountViewModel, + isInPipMode = isInPipMode, + isPipSupported = isPipSupported, + onMuteState = onMuteState, + onConnectedChange = onConnectedChange, + pipMuteSignal = pipMuteSignal, + onLeave = onLeave, + onMinimize = onMinimize, + ) + } +} + +/** + * Centered spinner shown between activity launch and the first + * resolved kind-30312 event. Replaces the previous black screen so + * the user knows the room is being loaded, not stuck. + */ +@Composable +private fun LoadingRoomState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator() + Text( + text = stringRes(R.string.nest_loading_room), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Terminal state for rooms whose kind-30312 doesn't carry the + * service/endpoint pair the audio plane needs (or whose address is + * malformed). Renders a clear explanation + a Back button so the + * user can leave instead of force-killing the activity. + */ +@Composable +private fun UnjoinableRoomState(onLeave: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringRes(R.string.nest_unjoinable_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Text( + text = stringRes(R.string.nest_unjoinable_body), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + OutlinedButton(onClick = onLeave) { + Text(stringRes(R.string.nest_unjoinable_back)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt index d4d243912f..5e67573eac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomEventCollectors.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent @@ -144,7 +146,15 @@ private fun ReactionsCollector( @Composable private fun ReactionsEvictionTicker(viewModel: NestViewModel) { - LaunchedEffect(viewModel) { + // Only tick while there are reactions to evict. The aggregator's + // last eviction empties [NestViewModel.recentReactions], which + // flips [hasReactions] to false and cancels the loop until the + // next reaction arrives. A perpetually-quiet room costs no + // scheduled work. + val reactions by viewModel.recentReactions.collectAsState() + val hasReactions = reactions.values.any { it.isNotEmpty() } + LaunchedEffect(viewModel, hasReactions) { + if (!hasReactions) return@LaunchedEffect while (isActive) { delay(REACTIONS_TICK_MS) viewModel.evictReactions(System.currentTimeMillis() / 1000 - REACTION_WINDOW_SEC_LOCAL) @@ -215,4 +225,4 @@ private const val REACTIONS_TICK_MS = 1_000L // — the platform layer doesn't import from commons here, and a // duplicate constant is cheaper than another import. If these // drift, the 1-s tick still self-heals within one window length. -private const val REACTION_WINDOW_SEC_LOCAL = 30L +private const val REACTION_WINDOW_SEC_LOCAL = 10L diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestViewModelFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestViewModelFactory.kt index 3b662fed45..e5d25b4751 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestViewModelFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestViewModelFactory.kt @@ -47,7 +47,11 @@ internal class NestViewModelFactory( @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = NestViewModel( - httpClient = OkHttpNestsClient(Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForVideo), + // Named parameter — `OkHttpNestsClient`'s primary constructor + // declares `callTimeoutMs` first with a default, so the + // function reference must be passed explicitly to the + // `httpClient` slot rather than as the first positional arg. + httpClient = OkHttpNestsClient(httpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForVideo), transport = QuicWebTransportFactory(), decoderFactory = { MediaCodecOpusDecoder() }, playerFactory = { AudioTrackPlayer() }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index 9e1200ccaf..4363fd3d37 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -242,6 +242,10 @@ private fun OnStageControls( is BroadcastUiState.Broadcasting -> { MicMuteToggle(isMuted = broadcast.isMuted, onToggle = viewModel::setMicMuted) + // Stop sending audio without leaving the stage — viewer + // can pause the mic and resume later via Talk. Distinct + // from [LeaveStageButton], which also vacates the slot. + StopBroadcastButton(onClick = viewModel::stopBroadcast) LeaveStageButton(onClick = leaveStage) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt index 1c3001ffb8..2d1421f82d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt @@ -24,22 +24,29 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel +import com.vitorpamplona.amethyst.commons.viewmodels.RoomPresence +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants.RoomParticipantActions import com.vitorpamplona.amethyst.ui.stringRes @@ -68,54 +75,79 @@ internal fun HandRaiseQueueSection( modifier: Modifier = Modifier, ) { val presences by viewModel.presences.collectAsState() + // Memoize the on-stage gate so a heartbeat that doesn't change + // the kind-30312 doesn't re-build the set every recompose. The + // event reference is stable across recompositions until the host + // republishes; presence updates run through the second remember. val onStageKeys = - event - .participants() - .filter { it.canSpeak() } - .map { it.pubKey } - .toSet() + remember(event) { + event + .participants() + .filter { it.canSpeak() } + .map { it.pubKey } + .toSet() + } val hands = - presences.values - .filter { it.handRaised && it.pubkey !in onStageKeys } - .sortedBy { it.updatedAtSec } + remember(presences, onStageKeys) { + presences.values + .filter { it.handRaised && it.pubkey !in onStageKeys } + .sortedBy { it.updatedAtSec } + } if (hands.isEmpty()) return val scope = rememberCoroutineScope() - Column(modifier = modifier.fillMaxWidth().padding(top = 12.dp)) { + Column(modifier = modifier.fillMaxSize().padding(top = 12.dp)) { Text( text = stringRes(R.string.nest_hand_raise_queue_title), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - hands.forEach { hand -> - Row( - modifier = Modifier.fillMaxWidth().padding(top = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - ClickableUserPicture( - baseUserHex = hand.pubkey, - size = Size35dp, + // LazyColumn so a flood of raised hands doesn't render every + // row every recompose. Stable key by pubkey lets Compose + // animate row entry/exit instead of teardown-rebuild. + LazyColumn(modifier = Modifier.fillMaxSize().padding(top = 6.dp)) { + items(items = hands, key = { it.pubkey }) { hand -> + HandRaiseRow( + hand = hand, accountViewModel = accountViewModel, - ) - Text( - text = hand.pubkey.take(8), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(4.dp)) - Button( - onClick = { + onApprove = { scope.launch { val template = RoomParticipantActions.setRole(event, hand.pubkey, ROLE.SPEAKER) template?.let { runCatching { accountViewModel.account.signAndComputeBroadcast(it) } } } }, - ) { - Text(stringRes(R.string.nest_hand_raise_approve)) - } + ) } } } } + +@Composable +private fun HandRaiseRow( + hand: RoomPresence, + accountViewModel: AccountViewModel, + onApprove: () -> Unit, +) { + val user = remember(hand.pubkey) { LocalCache.getOrCreateUser(hand.pubkey) } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ClickableUserPicture( + baseUserHex = hand.pubkey, + size = Size35dp, + accountViewModel = accountViewModel, + ) + UsernameDisplay( + baseUser = user, + weight = Modifier.weight(1f), + accountViewModel = accountViewModel, + ) + Spacer(Modifier.width(4.dp)) + Button(onClick = onApprove) { + Text(stringRes(R.string.nest_hand_raise_approve)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt index 3b883abaff..32ac342f84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/SpeakerReactionOverlay.kt @@ -21,39 +21,49 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.viewmodels.REACTION_WINDOW_SEC import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction +import kotlinx.coroutines.delay /** - * Floating-emoji overlay drawn under a speaker's avatar. Aggregates - * the same emoji into one chip with a count badge so a burst of - * "🔥🔥🔥" reads as `🔥 ×3` rather than three stacked chips. + * Floating-emoji overlay drawn under a speaker's avatar. Each chip + * is keyed by emoji content; bursts of the same emoji collapse into + * a single `🔥 ×3` chip whose count tracks live as new reactions land. * - * Hides itself when there are no reactions in the window — the - * 30-s sliding-window aggregator in - * [com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel.recentReactions] - * drops stale entries on the 1-s tick. + * Reactions are about what the speaker is saying RIGHT NOW, so each + * chip lives for [REACTION_WINDOW_SEC] seconds: the moment its + * youngest reaction arrives the chip fades+scales in, and over that + * window it slowly drifts upward and fades out before the eviction + * tick removes it from the underlying list. */ @Composable internal fun SpeakerReactionOverlay( reactions: List, modifier: Modifier = Modifier, ) { - // Wrap the row in AnimatedVisibility so the burst scales-and-fades - // in instead of snapping; a fading-out tail also smooths the - // 30 s eviction sweep instead of having chips just disappear. AnimatedVisibility( visible = reactions.isNotEmpty(), enter = fadeIn() + scaleIn(initialScale = 0.6f), @@ -64,10 +74,20 @@ internal fun SpeakerReactionOverlay( // "🔥 ×N" chip. Order by most recent so a fresh reaction lands // at the front of the row. val byContent = reactions.groupBy { it.content } - val ordered = byContent.toList().sortedByDescending { (_, list) -> list.maxOf { it.createdAtSec } } + val ordered = + byContent.toList().sortedByDescending { (_, list) -> + list.maxOf { it.createdAtSec } + } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { ordered.forEach { (content, list) -> - ReactionChip(content = content, count = list.size) + // Newest createdAt drives the chip's lifecycle — + // a fresh reaction restarts the upward-drift+fade. + val youngestSec = list.maxOf { it.createdAtSec } + ReactionChip( + content = content, + count = list.size, + youngestSec = youngestSec, + ) } } } @@ -77,11 +97,49 @@ internal fun SpeakerReactionOverlay( private fun ReactionChip( content: String, count: Int, + youngestSec: Long, ) { - // Tonal Surface picks up the right elevation tint in both light - // and dark themes — softer than the flat secondaryContainer fill - // and keeps a tiny shadow so the chip reads as floating. + // Tick a [progress] state from 0f → 1f over the eviction window + // so the chip can drift + fade in lockstep with how close it is + // to falling out of the aggregator. Reset whenever a fresher + // reaction lands by re-keying on [youngestSec]. + var progress by remember(youngestSec) { mutableStateOf(0f) } + LaunchedEffect(youngestSec) { + // Re-sync against wall-clock so a chip whose window started + // before this composable mounted (e.g. user rotated the + // device mid-burst) still ages correctly. + val ageMs = (System.currentTimeMillis() / 1000L - youngestSec).coerceAtLeast(0L) * 1000L + val remaining = (REACTION_WINDOW_MS - ageMs).coerceAtLeast(0L) + progress = (ageMs.toFloat() / REACTION_WINDOW_MS).coerceIn(0f, 1f) + if (remaining <= 0L) return@LaunchedEffect + // 100 ms ticks keep the drift smooth without burning a frame + // budget — the avatar is small and the chip's motion is + // sub-pixel between ticks anyway. + val steps = (remaining / 100L).coerceAtLeast(1L) + repeat(steps.toInt()) { + delay(100L) + progress = + ((System.currentTimeMillis() / 1000L - youngestSec).toFloat() * 1000f / REACTION_WINDOW_MS) + .coerceIn(0f, 1f) + } + progress = 1f + } + + // Drift up by ~16 dp over the window so the chip reads as + // "rising and dissipating", and fade out over the second half + // so the first half stays solidly readable. + val driftDp = (-16f * progress).dp + val animatedAlpha by animateFloatAsState( + targetValue = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f), + animationSpec = tween(durationMillis = 100, easing = LinearEasing), + label = "reaction-chip-alpha", + ) + Surface( + modifier = + Modifier + .offset(y = driftDp) + .alpha(animatedAlpha), shape = MaterialTheme.shapes.small, tonalElevation = 2.dp, shadowElevation = 1.dp, @@ -95,3 +153,5 @@ private fun ReactionChip( ) } } + +private const val REACTION_WINDOW_MS = REACTION_WINDOW_SEC * 1000L diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f0e4c8d2af..caf123df0b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -591,6 +591,10 @@ Minimize Minimize to keep listening Audio dropped + Loading room… + This room can\'t be opened + The room is missing an audio service or endpoint. The host needs to update its details before listeners can connect. + Back Start space Set up a nest server You haven\'t picked a nest server yet. Add %1$s to your server list and continue?\n\nYou can change this later in Settings. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt index da52af6970..e9c64fddf5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModel.kt @@ -139,10 +139,11 @@ class NestViewModel( /** * Chat ledger for the live-activities chat panel (#1) — every - * kind-1311 ([LiveActivitiesChatMessageEvent]) - * tagged with this room's `a`-pointer, ordered by `created_at` - * ascending so the newest message is at the end (the panel - * auto-scrolls to it). + * kind-1311 ([LiveActivitiesChatMessageEvent]) tagged with this + * room's `a`-pointer, ordered by [DefaultFeedOrder] (descending + * `created_at`, then by id for stability). The chat panel renders + * with `LazyColumn(reverseLayout = true)`, which puts data index 0 + * (the newest message) at the bottom of the viewport. * * Dedupes by event id — a relay re-emit on reconnect can't * produce a duplicate row. @@ -155,8 +156,8 @@ class NestViewModel( /** * Recent kind-7 reactions for the floating speaker-avatar overlay * (#3). Keyed by target pubkey; room-wide reactions land under the - * empty-string key. Sliding 30 s window driven by the platform - * layer's tick (typically every 1 s). + * empty-string key. Sliding [REACTION_WINDOW_SEC] window driven + * by the platform layer's tick (typically every 1 s). */ private val reactionsAgg = RoomReactionsAggregator() private val _recentReactions = MutableStateFlow>>(emptyMap()) @@ -371,7 +372,11 @@ class NestViewModel( */ fun onPresenceEvent(event: com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent) { if (closed) return - _presences.value = presenceAgg.apply(event) + // Skip the StateFlow write when the aggregator says nothing + // changed — saves a snapshot copy + an O(N) map equality check + // per replay in big rooms (LocalCache.observeEvents re-emits + // the full list on every cache mutation). + presenceAgg.applyOrNull(event)?.let { _presences.value = it } } /** @@ -401,8 +406,8 @@ class NestViewModel( /** * Apply one kind-7 reaction event to the sliding-window aggregator. * Caller passes [nowSec] (so tests can be deterministic) and the - * fixed 30-s window. Mirror of [evictReactions] for the per-tick - * cleanup. + * fixed [REACTION_WINDOW_SEC] window. Mirror of [evictReactions] + * for the per-tick cleanup. */ fun onReactionEvent( event: com.vitorpamplona.quartz.nip25Reactions.ReactionEvent, @@ -1222,10 +1227,13 @@ const val LEVEL_TICK_MS: Long = 100L /** * How long a kind-7 reaction stays in * [NestViewModel.recentReactions] before the eviction sweep - * drops it. Matches the duration of the floating-up animation in the - * SpeakerReactionOverlay. + * drops it. Reactions are about what the speaker is talking about + * RIGHT NOW — a 10 s window keeps the floating-emoji overlay + * timely (a reaction to one sentence shouldn't bleed into the + * next paragraph) and matches the duration of the fade-up + * animation in the SpeakerReactionOverlay. */ -const val REACTION_WINDOW_SEC: Long = 30L +const val REACTION_WINDOW_SEC: Long = 10L /** * Indirection over the top-level `connectNestsListener` so tests can drive diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceState.kt index 95c20a5766..a2b97b589f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomPresenceState.kt @@ -79,13 +79,31 @@ data class RoomPresence( class RoomPresenceAggregator { private val byPubkey = mutableMapOf() - /** Apply one presence event. Returns the updated snapshot. */ - fun apply(event: MeetingRoomPresenceEvent): Map { + /** + * Apply one presence event. Returns a fresh snapshot when state + * actually changed, or `null` when the event was a no-op (older + * or equal to the cached presence for the same pubkey). + * + * `LocalCache.observeEvents` re-emits the full matching list on + * every cache mutation, so in a 200-peer room the VM forwards + * 200 events for a single new heartbeat. Returning `null` for + * the 199 no-op replays lets the VM skip the + * `_presences.value = ...` write — saves both the snapshot copy + * and the StateFlow equality check (both O(N) per call). + */ + fun applyOrNull(event: MeetingRoomPresenceEvent): Map? { val incoming = RoomPresence.from(event) val current = byPubkey[incoming.pubkey] - if (current == null || current.updatedAtSec < incoming.updatedAtSec) { - byPubkey[incoming.pubkey] = incoming + if (current != null && current.updatedAtSec >= incoming.updatedAtSec) { + return null } + byPubkey[incoming.pubkey] = incoming + return snapshot() + } + + /** Apply-or-no-op variant kept for tests + callers that always want a snapshot. */ + fun apply(event: MeetingRoomPresenceEvent): Map { + applyOrNull(event) return snapshot() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt index 192095bb86..a538d1c592 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsState.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent */ @Immutable data class RoomReaction( + /** Source event id — used for dedup across re-emits. */ + val eventId: String, /** Who reacted. */ val sourcePubkey: String, /** Speaker the reaction is aimed at, or `null` for room-wide. */ @@ -52,6 +54,7 @@ data class RoomReaction( */ fun from(event: ReactionEvent): RoomReaction = RoomReaction( + eventId = event.id, sourcePubkey = event.pubKey, targetPubkey = event.originalAuthor().firstOrNull(), content = event.content, @@ -61,16 +64,19 @@ data class RoomReaction( } /** - * Sliding-window aggregator. Holds reactions keyed by target pubkey - * (with `null` lumped under the empty-string key so the map's - * value-type is uniform); the room screen reads - * `byTarget()` per render and the UI naturally fades as the window - * slides. + * Sliding-window aggregator. Holds reactions keyed by event id (so + * re-emits from `LocalCache.observeEvents` — which republishes the + * full matching list on every cache mutation — collapse into a + * single overlay entry instead of stacking duplicates), and surfaces + * them grouped by target pubkey for the UI (with `null` lumped under + * the empty-string key so the map's value-type is uniform). * * Not thread-safe — call from the VM's single coroutine. */ class RoomReactionsAggregator { - private val all = mutableListOf() + // Insertion-ordered so groupBy preserves arrival order; keyed by + // event id so the same kind-7 from two relays only counts once. + private val byEventId = LinkedHashMap() /** Stable key for room-wide reactions in the returned map. */ private val roomWideKey = "" @@ -81,7 +87,13 @@ class RoomReactionsAggregator { nowSec: Long, windowSec: Long, ): Map> { - all += RoomReaction.from(event) + val incoming = RoomReaction.from(event) + // Dedup: a relay re-delivery (or LocalCache.observeEvents's + // full-list re-emit) of the same kind-7 must not stack. + if (byEventId.put(incoming.eventId, incoming) != null) { + // Re-emit of an existing reaction: just return the post- + // evict snapshot without growing the map. + } return evictAndSnapshot(nowSec - windowSec) } @@ -92,7 +104,13 @@ class RoomReactionsAggregator { * per-Composable timer). */ fun evictAndSnapshot(olderThanSec: Long): Map> { - all.removeAll { it.createdAtSec < olderThanSec } - return all.groupBy { it.targetPubkey ?: roomWideKey } + val it = byEventId.entries.iterator() + while (it.hasNext()) { + if (it.next().value.createdAtSec < olderThanSec) it.remove() + } + return byEventId.values.groupBy { it.targetPubkey ?: roomWideKey } } + + /** Whether the aggregator currently holds any unevicted reactions. */ + fun isEmpty(): Boolean = byEventId.isEmpty() } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt index 101bdbb492..2cddb7a0db 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt @@ -286,13 +286,19 @@ class NestViewModelTest { val alice = "a".repeat(64) val bob = "b".repeat(64) + // Vary id per reaction so the dedup gate doesn't collapse + // distinct kind-7s; the relay-replay dedup case has its own + // assertion below. + var nextId = 1 + fun rxn( from: String, to: String, content: String, createdAt: Long, + id: String = "%064x".format(nextId++), ) = com.vitorpamplona.quartz.nip25Reactions.ReactionEvent( - id = "0".repeat(64), + id = id, pubKey = from, createdAt = createdAt, tags = arrayOf(arrayOf("a", "30312:host:room"), arrayOf("p", to)), @@ -305,7 +311,15 @@ class NestViewModelTest { vm.onReactionEvent(rxn(alice, bob, "👏", 105L), nowSec = 105L) assertEquals(2, vm.recentReactions.value[bob]!!.size) - // Tick advances past the window — both reactions evicted. + // LocalCache.observeEvents re-emits the full matching list + // on every cache mutation; the same kind-7 must collapse + // into one overlay entry instead of stacking on each replay. + val replayed = rxn(alice, bob, "🔥", 100L, id = "f".repeat(64)) + vm.onReactionEvent(replayed, nowSec = 105L) + vm.onReactionEvent(replayed, nowSec = 105L) + assertEquals(3, vm.recentReactions.value[bob]!!.size) + + // Tick advances past the window — all reactions evicted. vm.evictReactions(olderThanSec = 200L) assertEquals(emptyMap(), vm.recentReactions.value) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt index 820091ff01..38df298574 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RoomReactionsStateTest.kt @@ -31,11 +31,14 @@ class RoomReactionsStateTest { private val bob = "b".repeat(64) private val charlie = "c".repeat(64) + private var nextEventId = 1 + private fun reaction( from: String, to: String?, content: String, createdAt: Long, + id: String = "%064x".format(nextEventId++), ): ReactionEvent { val tags = buildList> { @@ -43,7 +46,7 @@ class RoomReactionsStateTest { if (to != null) add(arrayOf("p", to)) }.toTypedArray() return ReactionEvent( - id = "0".repeat(64), + id = id, pubKey = from, createdAt = createdAt, tags = tags, @@ -112,4 +115,20 @@ class RoomReactionsStateTest { // RoomReaction so the StateFlow doesn't re-emit on no-op ticks). assertEquals(a, b) } + + @Test + fun aggregatorDedupsRepeatedEventIds() { + val agg = RoomReactionsAggregator() + // LocalCache.observeEvents re-emits the full matching list on + // every cache mutation; the same kind-7 must collapse into one + // overlay entry instead of stacking on each replay. + val sharedId = "f".repeat(64) + val first = reaction(alice, bob, "🔥", 100L, id = sharedId) + val replay = reaction(alice, bob, "🔥", 100L, id = sharedId) + agg.apply(first, nowSec = 100L, windowSec = 30L) + agg.apply(replay, nowSec = 100L, windowSec = 30L) + val snap = agg.apply(replay, nowSec = 100L, windowSec = 30L) + + assertEquals(1, snap[bob]!!.size) + } }