From d21eb0e22662998f693d7853fb96402fbdc63784 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 13 May 2026 18:17:15 -0400 Subject: [PATCH] fix nests room host UX: visible toasts, promote propagation, edit-sheet keyboard, reaction animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../nests/room/activity/NestActivity.kt | 20 +++ .../room/activity/NestActivityContent.kt | 8 ++ .../loggedIn/nests/room/edit/EditNestSheet.kt | 121 +++++++++++------- .../ParticipantHostActionsSheet.kt | 101 +++++++++------ .../participants/RoomParticipantActions.kt | 14 ++ .../room/stage/SpeakerReactionOverlay.kt | 81 ++++++------ amethyst/src/main/res/values/strings.xml | 12 +- .../commons/viewmodels/ParticipantGrid.kt | 16 ++- .../commons/viewmodels/ParticipantGridTest.kt | 44 +++++++ 9 files changed, 277 insertions(+), 140 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt index 1ff53d481..80ab4b2ba 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivity.kt @@ -38,6 +38,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription 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.ManageWebOkHttp import com.vitorpamplona.amethyst.ui.theme.AmethystTheme @@ -190,6 +192,24 @@ class NestActivity : AppCompatActivity() { onLeave = { finish() }, 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(), + ) } } } 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 28a889ff0..d1a7096a6 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 @@ -251,6 +251,14 @@ private fun NestActivityBody( participants = event.participants(), presences = presences, 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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt index 8227f9519..494b1b938 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestSheet.kt @@ -26,9 +26,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator @@ -108,60 +111,80 @@ fun EditNestSheet( ) } 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( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().imePadding(), ) { - Text( - text = stringRes(R.string.nest_edit_title), - style = MaterialTheme.typography.titleLarge, - ) - - OutlinedTextField( - 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 -> + Column( + modifier = + Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Text( - text = error, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, + text = stringRes(R.string.nest_edit_title), + style = MaterialTheme.typography.titleLarge, ) - } - Spacer(Modifier.height(4.dp)) - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedTextField( + 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 // so subscribers see the room go dark. Confirm prompt // gates the actual publish so a misclick on the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt index 9d3dfa4fa..fc7dadce0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel 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.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent 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 @@ -100,12 +104,12 @@ internal fun ParticipantHostActionsSheet( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val targetUser = remember(target) { LocalCache.getOrCreateUser(target) } - // Reactive display name — UsernameDisplay observes this for the - // header, and we reuse the same flow for toast text so the header - // and toast never disagree (e.g. metadata arriving mid-sheet). + // Reactive display name for confirmation-dialog bodies (kick / + // force-mute) — `observeUserInfo` lets the dialog text track a + // late-arriving metadata event without re-opening the sheet. val userInfo by observeUserInfo(targetUser, accountViewModel) val displayName = userInfo?.info?.bestName() ?: targetUser.pubkeyDisplayHex() - val toasts = rememberParticipantToasts(displayName) + val toasts = rememberParticipantToasts() var openDialog by rememberSaveable(stateSaver = SheetDialogSaver) { mutableStateOf(null) @@ -174,7 +178,6 @@ internal fun ParticipantHostActionsSheet( // so future presence events don't re-render them broadcast(accountViewModel, AdminCommandEvent.kick(roomATag, target)) broadcast(accountViewModel, RoomParticipantActions.removeParticipant(event, target)) - accountViewModel.toastManager.toast(toasts.title, toasts.kickSent) onDismiss() }, onDismiss = { openDialog = null }, @@ -190,7 +193,6 @@ internal fun ParticipantHostActionsSheet( openDialog = null val roomATag = ATag(event.kind, event.pubKey, event.dTag(), null) broadcast(accountViewModel, AdminCommandEvent.forceMute(roomATag, target)) - accountViewModel.toastManager.toast(toasts.title, toasts.forceMuteSent) onDismiss() }, onDismiss = { openDialog = null }, @@ -273,10 +275,8 @@ private fun AudienceActions( ) { if (isFollowing) { accountViewModel.unfollow(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.unfollowSent) } else { accountViewModel.follow(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.followSent) } onDismiss() } @@ -289,10 +289,8 @@ private fun AudienceActions( ) { if (isHidden) { accountViewModel.show(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.unmuteSent) } else { accountViewModel.hide(targetUser) - accountViewModel.toastManager.toast(toasts.title, toasts.muteSent) } onDismiss() } @@ -337,13 +335,22 @@ private fun HostActions( val nestPresences by nestViewModel.presences.collectAsState() val targetIsBroadcasting = nestPresences[target]?.publishing == true - fun hostAction( - template: EventTemplate?, - toastMsg: String, - ) { - broadcast(accountViewModel, template) - accountViewModel.toastManager.toast(toasts.title, toastMsg) + fun hostAction(template: EventTemplate?) { + // Sign+publish runs inside a coroutine that surfaces ANY + // failure as a toast. Success is confirmed by the UI moving + // the target between stage/audience (or removing them), so + // no success toast is needed — the previous "Promoted X" + // 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() + broadcastWithDiagnostics(accountViewModel, template, toasts.failedTitle) } Spacer(Modifier.height(4.dp)) @@ -353,12 +360,12 @@ private fun HostActions( // wasted relay traffic and an obvious UX tell. if (targetRole != ROLE.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) { 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 — @@ -366,7 +373,7 @@ private fun HostActions( // and the visible row sets up a misleading expectation. if (targetRole != null && targetRole != ROLE.PARTICIPANT) { 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 @@ -498,6 +505,36 @@ private fun broadcast( 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, + 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( context: Context, target: String, @@ -545,33 +582,17 @@ private val SheetDialogSaver: Saver = * (which are not @Composable) read them by reference. */ private data class ParticipantToasts( - val title: String, - val followSent: 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 failedTitle: String, + val failedTemplateNull: String, val noAppMessage: String, val zapSplitUnsupported: String, ) @Composable -private fun rememberParticipantToasts(displayName: String): ParticipantToasts = +private fun rememberParticipantToasts(): ParticipantToasts = ParticipantToasts( - title = stringRes(R.string.nest_toast_action_title), - followSent = stringRes(R.string.nest_toast_follow_sent, displayName), - 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), + failedTitle = stringRes(R.string.nest_toast_host_action_failed_title), + failedTemplateNull = stringRes(R.string.nest_toast_host_action_failed_template_null), noAppMessage = stringRes(R.string.nest_no_app_to_open_link), zapSplitUnsupported = stringRes(R.string.nest_participant_zap_split_unsupported), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt index c7a396081..b28c392e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/RoomParticipantActions.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.summary import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE +import com.vitorpamplona.quartz.utils.TimeUtils /** * 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) 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( room = original.room().orEmpty(), status = status, service = original.service().orEmpty(), host = host, dTag = original.dTag(), + createdAt = nextCreatedAt, ) { original.endpoint()?.let { endpoint(it) } original.summary()?.takeIf { it.isNotBlank() }?.let { summary(it) } 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 32ac342f8..88068a9d6 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,8 +21,8 @@ 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.Animatable +import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -37,16 +37,11 @@ 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. Each chip @@ -99,47 +94,46 @@ private fun ReactionChip( count: Int, youngestSec: Long, ) { - // 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) } + // One-shot rise + fade keyed on [youngestSec], so a fresh + // reaction in the same emoji burst restarts the motion. The + // previous implementation re-derived `progress` on a 100 ms + // `delay` loop over the 10 s eviction window — drift came out + // 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) { - // 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) + // Wall-clock seed so a chip already mid-window when the + // composable first mounts (e.g. recompose after rotation) + // picks up where it left off rather than restarting. + val ageMs = ((System.currentTimeMillis() / 1000L) - youngestSec).coerceAtLeast(0L) * 1000L + val startProgress = (ageMs.toFloat() / REACTION_RISE_MS.toFloat()).coerceIn(0f, 1f) + rise.snapTo(startProgress) + val remainingMs = (REACTION_RISE_MS - ageMs).toInt().coerceAtLeast(0) + if (remainingMs > 0) { + rise.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = remainingMs, easing = LinearOutSlowInEasing), + ) } - 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", - ) + val progress = rise.value + // Final rise distance has to land within the participant cell's + // headroom — much past 24 dp clips into the avatar above on a + // 75 dp grid cell. + val driftDp = (-DRIFT_MAX_DP * progress).dp + // Solid for the first half, then fades linearly over the second + // half so the reaction reads as "popping in, then dissipating". + val alpha = (1f - ((progress - 0.5f).coerceAtLeast(0f) * 2f)).coerceIn(0f, 1f) Surface( modifier = Modifier .offset(y = driftDp) - .alpha(animatedAlpha), + .alpha(alpha), shape = MaterialTheme.shapes.small, tonalElevation = 2.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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a25a9cca6..a34c43df0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -689,16 +689,8 @@ Asks %1$s\'s client to mute its mic. Some clients may ignore this command. Force-mute Cancel - Audio room - Following %1$s. - Unfollowed %1$s. - Muted %1$s. - Unmuted %1$s. - Promoted %1$s to speaker. - Promoted %1$s to moderator. - Demoted %1$s to listener. - Force-muted %1$s. - Kicked %1$s from the room. + Action failed + The action could not be prepared. Share room Minimize Minimize to keep listening diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt index 877fb2f23..19e7868d3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt @@ -69,6 +69,16 @@ data class ParticipantGrid( * AND whose latest presence advertises `onstage != false`. A * speaker who explicitly emitted `onstage=0` ("step off the * 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 * stage, plus participant-tagged users who haven't emitted * presence yet (rendered as `absent = true`). @@ -86,6 +96,7 @@ fun buildParticipantGrid( participants: List, presences: Map, hostPubkey: HexKey? = null, + roleGrantSec: Long = 0L, ): ParticipantGrid { val effectiveParticipants = if (hostPubkey != null && participants.none { it.pubKey == hostPubkey }) { @@ -103,7 +114,10 @@ fun buildParticipantGrid( val pres = presences[p.pubKey] val role = p.effectiveRole() 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 = RoomMember( pubkey = p.pubKey, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt index 2a5ff4bcb..ac4871e3c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt @@ -168,6 +168,50 @@ class ParticipantGridTest { 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 fun explicitHostInPTagsIsNotDuplicated() { // The author already tagged themselves with role=host. The