This commit is contained in:
Vitor Pamplona
2026-05-01 15:59:30 -04:00
17 changed files with 570 additions and 135 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,
@@ -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())
@@ -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 =
@@ -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)) }
@@ -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)
}
@@ -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<MeetingSpaceEvent>(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))
}
}
}
@@ -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
@@ -47,7 +47,11 @@ internal class NestViewModelFactory(
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): 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() },
@@ -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)
}
@@ -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))
}
}
}
@@ -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<RoomReaction>,
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
+4
View File
@@ -591,6 +591,10 @@
<string name="nest_minimize">Minimize</string>
<string name="nest_minimize_description">Minimize to keep listening</string>
<string name="nest_audio_dropped">Audio dropped</string>
<string name="nest_loading_room">Loading room…</string>
<string name="nest_unjoinable_title">This room can\'t be opened</string>
<string name="nest_unjoinable_body">The room is missing an audio service or endpoint. The host needs to update its details before listeners can connect.</string>
<string name="nest_unjoinable_back">Back</string>
<string name="nest_create_fab">Start space</string>
<string name="nest_no_server_title">Set up a nest server</string>
<string name="nest_no_server_body">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.</string>
@@ -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<Map<String, List<RoomReaction>>>(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
@@ -79,13 +79,31 @@ data class RoomPresence(
class RoomPresenceAggregator {
private val byPubkey = mutableMapOf<String, RoomPresence>()
/** Apply one presence event. Returns the updated snapshot. */
fun apply(event: MeetingRoomPresenceEvent): Map<String, RoomPresence> {
/**
* 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<String, RoomPresence>? {
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<String, RoomPresence> {
applyOrNull(event)
return snapshot()
}
@@ -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<RoomReaction>()
// 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<String, RoomReaction>()
/** Stable key for room-wide reactions in the returned map. */
private val roomWideKey = ""
@@ -81,7 +87,13 @@ class RoomReactionsAggregator {
nowSec: Long,
windowSec: Long,
): Map<String, List<RoomReaction>> {
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<String, List<RoomReaction>> {
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()
}
@@ -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)
}
@@ -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<Array<String>> {
@@ -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)
}
}