fix nests room host UX: visible toasts, promote propagation, edit-sheet keyboard, reaction animation

This commit bundles several issues surfaced while testing host actions
inside an audio room.

NestActivity didn't mount `DisplayErrorMessages`, so every toast emitted
by nest code (promote, demote, kick, force-mute, errors) queued into a
StateFlow whose only collector lives in MainActivity. Mounted it inside
NestActivity.setContent so toasts now render in front of the room UI —
same way the leave-confirmation AlertDialog already does.

Host actions used to fire a synchronous "Promoted X" toast regardless of
whether `signAndComputeBroadcast` actually completed. Silent signer
failures (TimedOut, ManuallyUnauthorized, CouldNotPerform, etc. — all
Log.w-only in launchSigner) were invisible from the host's POV. Replaced
with a coroutine-bound failure toast that surfaces the exception class
+ message; success is implicit via the UI update.

The real cause of "promoted user stays in audience tab" turned out to be
stale presence: a kind-10312 emitted before the role grant (with
onstage=0) was pinning the freshly-promoted speaker to the audience tab.
buildParticipantGrid now takes a `roleGrantSec` parameter (the
kind-30312 created_at) and treats presence as authoritative only when
strictly newer. Pinned with two new tests covering both the
stale-ignore and fresh-respect cases. The leave-stage-on-another-client
flow keeps working because that emits a fresh onstage=0.

RoomParticipantActions.rebuild now uses
`(original.createdAt + 1L).coerceAtLeast(now())` so a same-second
promote→demote can't tie-break the wrong way under NIP-01's lowest-id
rule.

EditNestSheet's bottom row got squashed when the keyboard appeared —
the form fields couldn't shrink, so the Save/Cancel/Close row took the
hit. Split into a scrollable form column + sticky action row, wrapped
the outer column in imePadding.

SpeakerReactionOverlay was driving drift on a 100 ms `delay` loop over
the 10 s eviction window — produced 0.16 dp per tick (visibly stepped)
and the chip barely moved before disappearing. Replaced with
`Animatable.animateTo` on Compose's frame clock and a 6 s duration so
the chip pops, lingers visibly, then drifts up and fades.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-13 18:17:15 -04:00
parent 82ce4df201
commit d21eb0e226
9 changed files with 277 additions and 140 deletions
@@ -38,6 +38,8 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.StringResSetup import com.vitorpamplona.amethyst.ui.StringResSetup
import com.vitorpamplona.amethyst.ui.components.toasts.DisplayErrorMessages
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices
import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
@@ -190,6 +192,24 @@ class NestActivity : AppCompatActivity() {
onLeave = { finish() }, onLeave = { finish() },
onMinimize = { enterPip() }, onMinimize = { enterPip() },
) )
// Mirror MainActivity's `AppNavigation` → render toasts
// inside *this* Activity's Compose tree so promote /
// demote / kick / room-edit results actually surface in
// front of the room UI. Without this, every
// `toastManager.toast(...)` from nest code queues into a
// StateFlow whose only collector lives in MainActivity —
// the dialog only becomes visible after the user PIPs out.
// EmptyNav is the documented stub for hosts that have no
// navigation graph; the only consumer that reads `nav`
// is `MultiErrorToastMsg` (zap-error aggregator) — nest
// call sites emit `ResourceToastMsg` / `StringToastMsg`
// which don't touch nav.
DisplayErrorMessages(
toastManager = accountViewModel.toastManager,
accountViewModel = accountViewModel,
nav = EmptyNav(),
)
} }
} }
} }
@@ -251,6 +251,14 @@ private fun NestActivityBody(
participants = event.participants(), participants = event.participants(),
presences = presences, presences = presences,
hostPubkey = event.pubKey, hostPubkey = event.pubKey,
// Treat presence as authoritative only when fresher
// than the role grant. nostrnests audience members
// emit kind-10312 with `onstage=0` and never flip
// it back on after a promotion — without this gate,
// a freshly-promoted speaker would render in the
// audience tab with role=SPEAKER, looking unchanged
// to the host who just promoted them.
roleGrantSec = event.createdAt,
) )
} }
val onStageKeys = val onStageKeys =
@@ -26,9 +26,12 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -108,60 +111,80 @@ fun EditNestSheet(
) )
} }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
// Two-region layout so the keyboard never squashes the action
// row: the form scrolls inside its own viewport, the bottom
// buttons stay anchored. `imePadding()` on the outer Column
// lifts the whole sheet above the keyboard, so the anchored
// row sits just above the IME instead of behind it.
Column( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth().imePadding(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Column(
text = stringRes(R.string.nest_edit_title), modifier =
style = MaterialTheme.typography.titleLarge, Modifier
) .fillMaxWidth()
.weight(1f, fill = false)
OutlinedTextField( .verticalScroll(rememberScrollState())
value = state.roomName, .padding(horizontal = 16.dp, vertical = 8.dp),
onValueChange = viewModel::setRoomName, verticalArrangement = Arrangement.spacedBy(8.dp),
label = { Text(stringRes(R.string.nest_create_field_room)) }, ) {
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.summary,
onValueChange = viewModel::setSummary,
label = { Text(stringRes(R.string.nest_create_field_summary)) },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.serviceUrl,
onValueChange = viewModel::setServiceUrl,
label = { Text(stringRes(R.string.nest_create_field_service)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.endpointUrl,
onValueChange = viewModel::setEndpointUrl,
label = { Text(stringRes(R.string.nest_create_field_endpoint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.imageUrl,
onValueChange = viewModel::setImageUrl,
label = { Text(stringRes(R.string.nest_create_field_image)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
state.error?.let { error ->
Text( Text(
text = error, text = stringRes(R.string.nest_edit_title),
color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.bodySmall,
) )
}
Spacer(Modifier.height(4.dp)) OutlinedTextField(
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { value = state.roomName,
onValueChange = viewModel::setRoomName,
label = { Text(stringRes(R.string.nest_create_field_room)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.summary,
onValueChange = viewModel::setSummary,
label = { Text(stringRes(R.string.nest_create_field_summary)) },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.serviceUrl,
onValueChange = viewModel::setServiceUrl,
label = { Text(stringRes(R.string.nest_create_field_service)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.endpointUrl,
onValueChange = viewModel::setEndpointUrl,
label = { Text(stringRes(R.string.nest_create_field_endpoint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.imageUrl,
onValueChange = viewModel::setImageUrl,
label = { Text(stringRes(R.string.nest_create_field_image)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
state.error?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(Modifier.height(4.dp))
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
// Destructive — flips status to CLOSED with the same d-tag // Destructive — flips status to CLOSED with the same d-tag
// so subscribers see the room go dark. Confirm prompt // so subscribers see the room go dark. Confirm prompt
// gates the actual publish so a misclick on the // gates the actual publish so a misclick on the
@@ -50,6 +50,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog import com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog
@@ -71,6 +72,9 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/** /**
* Per-participant context sheet (T2 #2). Long-press on any participant * Per-participant context sheet (T2 #2). Long-press on any participant
@@ -100,12 +104,12 @@ internal fun ParticipantHostActionsSheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val targetUser = remember(target) { LocalCache.getOrCreateUser(target) } val targetUser = remember(target) { LocalCache.getOrCreateUser(target) }
// Reactive display name — UsernameDisplay observes this for the // Reactive display name for confirmation-dialog bodies (kick /
// header, and we reuse the same flow for toast text so the header // force-mute) — `observeUserInfo` lets the dialog text track a
// and toast never disagree (e.g. metadata arriving mid-sheet). // late-arriving metadata event without re-opening the sheet.
val userInfo by observeUserInfo(targetUser, accountViewModel) val userInfo by observeUserInfo(targetUser, accountViewModel)
val displayName = userInfo?.info?.bestName() ?: targetUser.pubkeyDisplayHex() val displayName = userInfo?.info?.bestName() ?: targetUser.pubkeyDisplayHex()
val toasts = rememberParticipantToasts(displayName) val toasts = rememberParticipantToasts()
var openDialog by rememberSaveable(stateSaver = SheetDialogSaver) { var openDialog by rememberSaveable(stateSaver = SheetDialogSaver) {
mutableStateOf<SheetDialog?>(null) mutableStateOf<SheetDialog?>(null)
@@ -174,7 +178,6 @@ internal fun ParticipantHostActionsSheet(
// so future presence events don't re-render them // so future presence events don't re-render them
broadcast(accountViewModel, AdminCommandEvent.kick(roomATag, target)) broadcast(accountViewModel, AdminCommandEvent.kick(roomATag, target))
broadcast(accountViewModel, RoomParticipantActions.removeParticipant(event, target)) broadcast(accountViewModel, RoomParticipantActions.removeParticipant(event, target))
accountViewModel.toastManager.toast(toasts.title, toasts.kickSent)
onDismiss() onDismiss()
}, },
onDismiss = { openDialog = null }, onDismiss = { openDialog = null },
@@ -190,7 +193,6 @@ internal fun ParticipantHostActionsSheet(
openDialog = null openDialog = null
val roomATag = ATag(event.kind, event.pubKey, event.dTag(), null) val roomATag = ATag(event.kind, event.pubKey, event.dTag(), null)
broadcast(accountViewModel, AdminCommandEvent.forceMute(roomATag, target)) broadcast(accountViewModel, AdminCommandEvent.forceMute(roomATag, target))
accountViewModel.toastManager.toast(toasts.title, toasts.forceMuteSent)
onDismiss() onDismiss()
}, },
onDismiss = { openDialog = null }, onDismiss = { openDialog = null },
@@ -273,10 +275,8 @@ private fun AudienceActions(
) { ) {
if (isFollowing) { if (isFollowing) {
accountViewModel.unfollow(targetUser) accountViewModel.unfollow(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.unfollowSent)
} else { } else {
accountViewModel.follow(targetUser) accountViewModel.follow(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.followSent)
} }
onDismiss() onDismiss()
} }
@@ -289,10 +289,8 @@ private fun AudienceActions(
) { ) {
if (isHidden) { if (isHidden) {
accountViewModel.show(targetUser) accountViewModel.show(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.unmuteSent)
} else { } else {
accountViewModel.hide(targetUser) accountViewModel.hide(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.muteSent)
} }
onDismiss() onDismiss()
} }
@@ -337,13 +335,22 @@ private fun HostActions(
val nestPresences by nestViewModel.presences.collectAsState() val nestPresences by nestViewModel.presences.collectAsState()
val targetIsBroadcasting = nestPresences[target]?.publishing == true val targetIsBroadcasting = nestPresences[target]?.publishing == true
fun hostAction( fun hostAction(template: EventTemplate<out Event>?) {
template: EventTemplate<out Event>?, // Sign+publish runs inside a coroutine that surfaces ANY
toastMsg: String, // failure as a toast. Success is confirmed by the UI moving
) { // the target between stage/audience (or removing them), so
broadcast(accountViewModel, template) // no success toast is needed — the previous "Promoted X"
accountViewModel.toastManager.toast(toasts.title, toastMsg) // toast was redundant once the kind-30312 + stale-presence
// fixes made the grid update visible. Silent signer failures
// (NIP-46 timeout, manual denial, etc.) still need to
// surface because they're invisible from the host's POV.
if (template == null) {
accountViewModel.toastManager.toast(toasts.failedTitle, toasts.failedTemplateNull)
onDismiss()
return
}
onDismiss() onDismiss()
broadcastWithDiagnostics(accountViewModel, template, toasts.failedTitle)
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
@@ -353,12 +360,12 @@ private fun HostActions(
// wasted relay traffic and an obvious UX tell. // wasted relay traffic and an obvious UX tell.
if (targetRole != ROLE.SPEAKER) { if (targetRole != ROLE.SPEAKER) {
ActionRow(stringRes(R.string.nest_promote_speaker)) { ActionRow(stringRes(R.string.nest_promote_speaker)) {
hostAction(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER), toasts.promoteSpeakerSent) hostAction(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER))
} }
} }
if (targetRole != ROLE.MODERATOR) { if (targetRole != ROLE.MODERATOR) {
ActionRow(stringRes(R.string.nest_promote_moderator)) { ActionRow(stringRes(R.string.nest_promote_moderator)) {
hostAction(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR), toasts.promoteModeratorSent) hostAction(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR))
} }
} }
// Demote only when the target actually has a role above listener — // Demote only when the target actually has a role above listener —
@@ -366,7 +373,7 @@ private fun HostActions(
// and the visible row sets up a misleading expectation. // and the visible row sets up a misleading expectation.
if (targetRole != null && targetRole != ROLE.PARTICIPANT) { if (targetRole != null && targetRole != ROLE.PARTICIPANT) {
ActionRow(stringRes(R.string.nest_demote_listener)) { ActionRow(stringRes(R.string.nest_demote_listener)) {
hostAction(RoomParticipantActions.demoteToListener(event, target), toasts.demoteListenerSent) hostAction(RoomParticipantActions.demoteToListener(event, target))
} }
} }
// Force-mute is honor-based: the target's client must honor the // Force-mute is honor-based: the target's client must honor the
@@ -498,6 +505,36 @@ private fun broadcast(
accountViewModel.launchSigner { accountViewModel.account.signAndComputeBroadcast(template) } accountViewModel.launchSigner { accountViewModel.account.signAndComputeBroadcast(template) }
} }
/**
* Sign+publish a host-action template and surface only FAILURES as
* a toast. Success is implicit — the UI updates when the kind-30312
* round-trips, so a success toast would be redundant. Any throw
* (including the silent ones [launchSigner] Log.w-only's —
* `TimedOutException`, `ManuallyUnauthorizedException`,
* `CouldNotPerformException`,
* `RunningOnBackgroundWithoutAutomaticPermissionException`,
* `AutomaticallyUnauthorizedException`) becomes a visible error
* toast with the exception's class name + message so the host knows
* a remote-signer denial happened.
*/
private fun broadcastWithDiagnostics(
accountViewModel: AccountViewModel,
template: EventTemplate<out Event>,
failedTitle: String,
) {
accountViewModel.viewModelScope.launch(Dispatchers.IO) {
try {
accountViewModel.account.signAndComputeBroadcast(template)
} catch (ce: CancellationException) {
throw ce
} catch (e: Throwable) {
val klass = e::class.simpleName ?: "Throwable"
val msg = e.message?.takeIf { it.isNotBlank() } ?: "no message"
accountViewModel.toastManager.toast(failedTitle, "$klass: $msg")
}
}
}
private fun openProfileInMainActivity( private fun openProfileInMainActivity(
context: Context, context: Context,
target: String, target: String,
@@ -545,33 +582,17 @@ private val SheetDialogSaver: Saver<SheetDialog?, String> =
* (which are not @Composable) read them by reference. * (which are not @Composable) read them by reference.
*/ */
private data class ParticipantToasts( private data class ParticipantToasts(
val title: String, val failedTitle: String,
val followSent: String, val failedTemplateNull: String,
val unfollowSent: String,
val muteSent: String,
val unmuteSent: String,
val promoteSpeakerSent: String,
val promoteModeratorSent: String,
val demoteListenerSent: String,
val forceMuteSent: String,
val kickSent: String,
val noAppMessage: String, val noAppMessage: String,
val zapSplitUnsupported: String, val zapSplitUnsupported: String,
) )
@Composable @Composable
private fun rememberParticipantToasts(displayName: String): ParticipantToasts = private fun rememberParticipantToasts(): ParticipantToasts =
ParticipantToasts( ParticipantToasts(
title = stringRes(R.string.nest_toast_action_title), failedTitle = stringRes(R.string.nest_toast_host_action_failed_title),
followSent = stringRes(R.string.nest_toast_follow_sent, displayName), failedTemplateNull = stringRes(R.string.nest_toast_host_action_failed_template_null),
unfollowSent = stringRes(R.string.nest_toast_unfollow_sent, displayName),
muteSent = stringRes(R.string.nest_toast_mute_sent, displayName),
unmuteSent = stringRes(R.string.nest_toast_unmute_sent, displayName),
promoteSpeakerSent = stringRes(R.string.nest_toast_promote_speaker_sent, displayName),
promoteModeratorSent = stringRes(R.string.nest_toast_promote_moderator_sent, displayName),
demoteListenerSent = stringRes(R.string.nest_toast_demote_listener_sent, displayName),
forceMuteSent = stringRes(R.string.nest_toast_force_mute_sent, displayName),
kickSent = stringRes(R.string.nest_toast_kick_sent, displayName),
noAppMessage = stringRes(R.string.nest_no_app_to_open_link), noAppMessage = stringRes(R.string.nest_no_app_to_open_link),
zapSplitUnsupported = stringRes(R.string.nest_participant_zap_split_unsupported), zapSplitUnsupported = stringRes(R.string.nest_participant_zap_split_unsupported),
) )
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.summary
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import com.vitorpamplona.quartz.utils.TimeUtils
/** /**
* Pure builders for participant-list mutations on a kind-30312 * Pure builders for participant-list mutations on a kind-30312
@@ -132,12 +133,25 @@ internal object RoomParticipantActions {
?: ParticipantTag(original.pubKey, null, ROLE.HOST.code, null) ?: ParticipantTag(original.pubKey, null, ROLE.HOST.code, null)
val others = participants.filterNot { it.pubKey == host.pubKey } val others = participants.filterNot { it.pubKey == host.pubKey }
// Strictly monotonic createdAt. NIP-01 replaceable-event
// semantics drop a new version whose createdAt is <= the
// previous one (with a lower-id tie-break that's effectively
// random under signing). Creating a room then immediately
// promoting in the same wall-clock second produced the
// dreaded silent-no-op: the local cache rejected the new
// event in `consumeBaseReplaceable` (strict `>`) and relays
// applied the same rule. `+ 1` is enough to win the tie;
// `coerceAtLeast(now)` keeps the wire timestamp accurate
// when the original is already in the past.
val nextCreatedAt = (original.createdAt + 1L).coerceAtLeast(TimeUtils.now())
return MeetingSpaceEvent.build( return MeetingSpaceEvent.build(
room = original.room().orEmpty(), room = original.room().orEmpty(),
status = status, status = status,
service = original.service().orEmpty(), service = original.service().orEmpty(),
host = host, host = host,
dTag = original.dTag(), dTag = original.dTag(),
createdAt = nextCreatedAt,
) { ) {
original.endpoint()?.let { endpoint(it) } original.endpoint()?.let { endpoint(it) }
original.summary()?.takeIf { it.isNotBlank() }?.let { summary(it) } original.summary()?.takeIf { it.isNotBlank() }?.let { summary(it) }
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.stage
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -37,16 +37,11 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.viewmodels.REACTION_WINDOW_SEC
import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction import com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction
import kotlinx.coroutines.delay
/** /**
* Floating-emoji overlay drawn under a speaker's avatar. Each chip * Floating-emoji overlay drawn under a speaker's avatar. Each chip
@@ -99,47 +94,46 @@ private fun ReactionChip(
count: Int, count: Int,
youngestSec: Long, youngestSec: Long,
) { ) {
// Tick a [progress] state from 0f → 1f over the eviction window // One-shot rise + fade keyed on [youngestSec], so a fresh
// so the chip can drift + fade in lockstep with how close it is // reaction in the same emoji burst restarts the motion. The
// to falling out of the aggregator. Reset whenever a fresher // previous implementation re-derived `progress` on a 100 ms
// reaction lands by re-keying on [youngestSec]. // `delay` loop over the 10 s eviction window — drift came out
var progress by remember(youngestSec) { mutableStateOf(0f) } // to ~0.16 dp per tick (visibly stepped) and the rise barely
// moved over the full lifetime. Using `Animatable` drives the
// value on Compose's frame clock at full refresh rate, and the
// shorter [REACTION_RISE_MS] duration produces an actual
// pop-and-rise instead of a slow creep.
val rise = remember(youngestSec) { Animatable(0f) }
LaunchedEffect(youngestSec) { LaunchedEffect(youngestSec) {
// Re-sync against wall-clock so a chip whose window started // Wall-clock seed so a chip already mid-window when the
// before this composable mounted (e.g. user rotated the // composable first mounts (e.g. recompose after rotation)
// device mid-burst) still ages correctly. // picks up where it left off rather than restarting.
val ageMs = (System.currentTimeMillis() / 1000L - youngestSec).coerceAtLeast(0L) * 1000L val ageMs = ((System.currentTimeMillis() / 1000L) - youngestSec).coerceAtLeast(0L) * 1000L
val remaining = (REACTION_WINDOW_MS - ageMs).coerceAtLeast(0L) val startProgress = (ageMs.toFloat() / REACTION_RISE_MS.toFloat()).coerceIn(0f, 1f)
progress = (ageMs.toFloat() / REACTION_WINDOW_MS).coerceIn(0f, 1f) rise.snapTo(startProgress)
if (remaining <= 0L) return@LaunchedEffect val remainingMs = (REACTION_RISE_MS - ageMs).toInt().coerceAtLeast(0)
// 100 ms ticks keep the drift smooth without burning a frame if (remainingMs > 0) {
// budget — the avatar is small and the chip's motion is rise.animateTo(
// sub-pixel between ticks anyway. targetValue = 1f,
val steps = (remaining / 100L).coerceAtLeast(1L) animationSpec = tween(durationMillis = remainingMs, easing = LinearOutSlowInEasing),
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 val progress = rise.value
// "rising and dissipating", and fade out over the second half // Final rise distance has to land within the participant cell's
// so the first half stays solidly readable. // headroom — much past 24 dp clips into the avatar above on a
val driftDp = (-16f * progress).dp // 75 dp grid cell.
val animatedAlpha by animateFloatAsState( val driftDp = (-DRIFT_MAX_DP * progress).dp
targetValue = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f), // Solid for the first half, then fades linearly over the second
animationSpec = tween(durationMillis = 100, easing = LinearEasing), // half so the reaction reads as "popping in, then dissipating".
label = "reaction-chip-alpha", val alpha = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f)
)
Surface( Surface(
modifier = modifier =
Modifier Modifier
.offset(y = driftDp) .offset(y = driftDp)
.alpha(animatedAlpha), .alpha(alpha),
shape = MaterialTheme.shapes.small, shape = MaterialTheme.shapes.small,
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 1.dp, shadowElevation = 1.dp,
@@ -154,4 +148,11 @@ private fun ReactionChip(
} }
} }
private const val REACTION_WINDOW_MS = REACTION_WINDOW_SEC * 1000L // Rise + fade plays out well inside the 10 s eviction window so the
// chip is fully transparent before the aggregator drops it from the
// list (avoids a hard pop on removal). 6 s keeps the chip solidly
// visible for ~3 s (alpha=1 holds for the first half) before the
// fade begins — earlier 1.5 s and 4 s passes still read as a
// "blink" relative to nostrnests' web reaction lifetime.
private const val REACTION_RISE_MS = 6000L
private const val DRIFT_MAX_DP = 28f
+2 -10
View File
@@ -689,16 +689,8 @@
<string name="nest_confirm_force_mute_body">Asks %1$s\'s client to mute its mic. Some clients may ignore this command.</string> <string name="nest_confirm_force_mute_body">Asks %1$s\'s client to mute its mic. Some clients may ignore this command.</string>
<string name="nest_confirm_force_mute_confirm">Force-mute</string> <string name="nest_confirm_force_mute_confirm">Force-mute</string>
<string name="nest_confirm_cancel">Cancel</string> <string name="nest_confirm_cancel">Cancel</string>
<string name="nest_toast_action_title">Audio room</string> <string name="nest_toast_host_action_failed_title">Action failed</string>
<string name="nest_toast_follow_sent">Following %1$s.</string> <string name="nest_toast_host_action_failed_template_null">The action could not be prepared.</string>
<string name="nest_toast_unfollow_sent">Unfollowed %1$s.</string>
<string name="nest_toast_mute_sent">Muted %1$s.</string>
<string name="nest_toast_unmute_sent">Unmuted %1$s.</string>
<string name="nest_toast_promote_speaker_sent">Promoted %1$s to speaker.</string>
<string name="nest_toast_promote_moderator_sent">Promoted %1$s to moderator.</string>
<string name="nest_toast_demote_listener_sent">Demoted %1$s to listener.</string>
<string name="nest_toast_force_mute_sent">Force-muted %1$s.</string>
<string name="nest_toast_kick_sent">Kicked %1$s from the room.</string>
<string name="nest_share_action">Share room</string> <string name="nest_share_action">Share room</string>
<string name="nest_minimize">Minimize</string> <string name="nest_minimize">Minimize</string>
<string name="nest_minimize_description">Minimize to keep listening</string> <string name="nest_minimize_description">Minimize to keep listening</string>
@@ -69,6 +69,16 @@ data class ParticipantGrid(
* AND whose latest presence advertises `onstage != false`. A * AND whose latest presence advertises `onstage != false`. A
* speaker who explicitly emitted `onstage=0` ("step off the * speaker who explicitly emitted `onstage=0` ("step off the
* stage") drops to audience without losing their role tag. * stage") drops to audience without losing their role tag.
*
* Caveat: a presence event is only honoured for the on-stage
* gate when it's NEWER than the role grant ([roleGrantSec],
* conservatively the kind-30312 `created_at`). Otherwise a
* freshly-promoted audience member whose pre-promotion
* `kind-10312` carried `onstage=0` would be pinned to the
* audience tab until they re-heartbeat which on nostrnests
* never happens, since audience members there don't toggle
* their `onstage` flag back on after a role bump. Stale
* presence trust the role.
* * Audience: every pubkey with recent presence that isn't on * * Audience: every pubkey with recent presence that isn't on
* stage, plus participant-tagged users who haven't emitted * stage, plus participant-tagged users who haven't emitted
* presence yet (rendered as `absent = true`). * presence yet (rendered as `absent = true`).
@@ -86,6 +96,7 @@ fun buildParticipantGrid(
participants: List<ParticipantTag>, participants: List<ParticipantTag>,
presences: Map<String, RoomPresence>, presences: Map<String, RoomPresence>,
hostPubkey: HexKey? = null, hostPubkey: HexKey? = null,
roleGrantSec: Long = 0L,
): ParticipantGrid { ): ParticipantGrid {
val effectiveParticipants = val effectiveParticipants =
if (hostPubkey != null && participants.none { it.pubKey == hostPubkey }) { if (hostPubkey != null && participants.none { it.pubKey == hostPubkey }) {
@@ -103,7 +114,10 @@ fun buildParticipantGrid(
val pres = presences[p.pubKey] val pres = presences[p.pubKey]
val role = p.effectiveRole() val role = p.effectiveRole()
val canSpeak = p.canSpeak() val canSpeak = p.canSpeak()
val onstageFlag = pres?.onstage ?: true // default true for backwards compat // Stale-presence override: trust the role when the presence
// event predates the role grant. See KDoc above for why.
val presenceIsFresh = pres != null && pres.updatedAtSec >= roleGrantSec
val onstageFlag = if (presenceIsFresh) pres.onstage else true
val member = val member =
RoomMember( RoomMember(
pubkey = p.pubKey, pubkey = p.pubKey,
@@ -168,6 +168,50 @@ class ParticipantGridTest {
assertEquals(ROLE.HOST, hostRow?.role) assertEquals(ROLE.HOST, hostRow?.role)
} }
@Test
fun stalePresenceWithOnstageFalseIsIgnoredAfterRoleGrant() {
// Audience-promoted-to-speaker scenario: presence event was
// emitted at t=100 with onstage=false (their pre-promotion
// audience-mode kind-10312). The host then promotes them at
// t=200 (the room's new createdAt). Without the staleness
// gate, this lands the speaker in the audience tab even
// though their role just changed — the bug reported on
// 2026-05-13. With roleGrantSec=200, the t=100 presence is
// stale and the role tag wins → speaker on stage.
val grid =
buildParticipantGrid(
participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)),
presences =
mapOf(
host to presence(host, 200L),
speaker to presence(speaker, 100L, onstage = false),
),
roleGrantSec = 200L,
)
assertEquals(setOf(host, speaker), grid.onStage.map { it.pubkey }.toSet())
assertEquals(0, grid.audience.size)
}
@Test
fun freshOnstageFalseAfterRoleGrantStillDropsToAudience() {
// Inverse: speaker emits onstage=0 AFTER the role grant
// (deliberate "stepped off stage" while keeping the role).
// Should still drop to audience — the existing leave-stage
// contract.
val grid =
buildParticipantGrid(
participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)),
presences =
mapOf(
host to presence(host, 200L),
speaker to presence(speaker, 300L, onstage = false),
),
roleGrantSec = 200L,
)
assertEquals(setOf(host), grid.onStage.map { it.pubkey }.toSet())
assertEquals(setOf(speaker), grid.audience.map { it.pubkey }.toSet())
}
@Test @Test
fun explicitHostInPTagsIsNotDuplicated() { fun explicitHostInPTagsIsNotDuplicated() {
// The author already tagged themselves with role=host. The // The author already tagged themselves with role=host. The