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:
+20
@@ -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(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -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 =
|
||||||
|
|||||||
+25
-2
@@ -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,8 +111,21 @@ 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(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f, fill = false)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -161,7 +177,14 @@ fun EditNestSheet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
}
|
||||||
|
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
|
||||||
|
|||||||
+61
-40
@@ -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()
|
onDismiss()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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),
|
||||||
)
|
)
|
||||||
|
|||||||
+14
@@ -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) }
|
||||||
|
|||||||
+41
-40
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+15
-1
@@ -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,
|
||||||
|
|||||||
+44
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user