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:
+15
-1
@@ -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,
|
||||
|
||||
+44
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user