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
@@ -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<ParticipantTag>,
presences: Map<String, RoomPresence>,
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,
@@ -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