fix(nests): emit canonical title tag and keep mute badge visible while muted

Two interrelated bugs from the deployed-schema flip:

1. Rooms created in Amethyst didn't appear in nostrnests's "Live Now"
   lobby. The lobby filter in NestsUI's skeleton.js requires a `title`
   tag on every kind:30312 — events without one are dropped from
   `live`/`planned`/`ended`. RoomNameTag still emitted the legacy
   `room` name, so our rooms passed our own readers (which tolerate
   both names) but were invisible to NestsUI. Flip canonical to
   `title`, keep `room` as legacy on read.

2. The mute badge over the avatar disappeared shortly after toggling
   mute, even though the broadcaster was still muted. Two causes:

   - ParticipantsGrid gated both the muted ring and the MicStateBadge
     on `member.publishing`. But per the deployed kind-10312 semantics
     `publishing=0` whenever `muted=1` (NestRoomPresencePublisher
     mirrors this — `publishingNow = broadcasting && !isMuted`), so
     the badge that was supposed to *indicate* mute was hidden
     exactly when it became relevant. Show the badge when on stage
     and either publishing OR muted; gate the muted ring on `muted`
     alone.

   - The presence heartbeat captured `micMutedTag` / `publishingTag`
     / `onstageTag` / `handRaised` at LaunchedEffect launch and never
     refreshed them. The 500 ms debounced LaunchedEffect did publish
     the new mute state, but ~30 s later the heartbeat overwrote it
     with the captured pre-toggle values, flipping `publishing`
     back to 1 and `muted` back to 0 server-side. Wrap the dynamic
     fields in `rememberUpdatedState` so the heartbeat reads the
     latest snapshot on every refresh; keep the existing key set
     (event/handRaised/onstage) for prompt re-emission on those
     transitions.

Tests updated for the new canonical `title` emission; both jvmTest
suites pass.
This commit is contained in:
Claude
2026-04-29 20:45:32 +00:00
parent aadb347b84
commit 0fafd34537
5 changed files with 52 additions and 15 deletions
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
@@ -43,9 +45,12 @@ import kotlinx.coroutines.launch
* onstage transition, then every [PRESENCE_REFRESH_MS]. Mute * onstage transition, then every [PRESENCE_REFRESH_MS]. Mute
* and publishing flags are intentionally NOT keys: every mute * and publishing flags are intentionally NOT keys: every mute
* toggle would otherwise round-trip a presence emit (audit * toggle would otherwise round-trip a presence emit (audit
* Android #11). The next heartbeat picks them up within 30 s, * Android #11). The heartbeat reads them via
* which is well within the user's "did the peer see my mute" * [rememberUpdatedState] so each refresh picks up the latest
* tolerance. * value WITHOUT cancelling and restarting the loop — without
* that wrapper the captured-at-launch values stay frozen and
* a 30 s refresh would overwrite a fresh mute toggle with the
* pre-toggle state, hiding the avatar's mute icon.
* *
* - **Debounce** — after a mute toggle, wait * - **Debounce** — after a mute toggle, wait
* [PRESENCE_DEBOUNCE_MS] for further changes before sending a * [PRESENCE_DEBOUNCE_MS] for further changes before sending a
@@ -77,11 +82,21 @@ internal fun NestPresencePublisher(
val publishingTag: Boolean = ui.publishingNow val publishingTag: Boolean = ui.publishingNow
val onstageTag: Boolean = ui.onStageNow val onstageTag: Boolean = ui.onStageNow
// Latest snapshots for the heartbeat. Without these, the
// LaunchedEffect captures the values from FIRST composition; the
// 30 s refresh would then overwrite a recent mute toggle with the
// pre-toggle state, which presented as "the mute icon disappeared
// a few seconds after I muted, but the mic is still hot."
val currentHandRaised by rememberUpdatedState(handRaised)
val currentMicMuted by rememberUpdatedState(micMutedTag)
val currentPublishing by rememberUpdatedState(publishingTag)
val currentOnstage by rememberUpdatedState(onstageTag)
LaunchedEffect(event.address().toValue(), handRaised, onstageTag) { LaunchedEffect(event.address().toValue(), handRaised, onstageTag) {
publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag) publishPresence(account, event, currentHandRaised, currentMicMuted, currentPublishing, currentOnstage)
while (isActive) { while (isActive) {
delay(PRESENCE_REFRESH_MS) delay(PRESENCE_REFRESH_MS)
publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag) publishPresence(account, event, currentHandRaised, currentMicMuted, currentPublishing, currentOnstage)
} }
} }
@@ -355,10 +355,15 @@ private fun MemberCell(
// Both color and width crossfade so going idle → speaking → idle // Both color and width crossfade so going idle → speaking → idle
// doesn't snap; the color animates from Transparent through the // doesn't snap; the color animates from Transparent through the
// speaking green and the width tracks the live peak amplitude. // speaking green and the width tracks the live peak amplitude.
// A speaker on stage with mic-mute on emits kind-10312 with
// `publishing=0, muted=1` (deployed nostrnests semantics — see
// EGG-04 / NestRoomPresencePublisher), so the muted ring must NOT
// gate on `publishing`; muting would otherwise hide the very
// indicator it was supposed to surface.
val targetRingColor = val targetRingColor =
when { when {
isSpeaking -> NEST_SPEAKING_COLOR isSpeaking -> NEST_SPEAKING_COLOR
showMicBadge && member.publishing && member.muted == true -> mutedRingColor showMicBadge && member.muted == true -> mutedRingColor
else -> Color.Transparent else -> Color.Transparent
} }
val targetRingWidth = val targetRingWidth =
@@ -445,7 +450,13 @@ private fun MemberCell(
modifier = Modifier.align(Alignment.TopEnd), modifier = Modifier.align(Alignment.TopEnd),
) )
} }
if (showMicBadge && member.publishing) { // Show the mic badge for any on-stage speaker that has
// an audio state to surface — currently broadcasting
// (`publishing=1`) OR mic-muted (`muted=1, publishing=0`).
// Gating only on `publishing` would hide the muted icon
// the moment the user mutes, which is exactly when it's
// supposed to appear.
if (showMicBadge && (member.publishing || member.muted == true)) {
MicStateBadge( MicStateBadge(
isSpeaking = isSpeaking, isSpeaking = isSpeaking,
isMuted = member.muted == true, isMuted = member.muted == true,
@@ -91,8 +91,12 @@ class EditNestViewModelTest {
val dTag = template.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1) val dTag = template.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1)
assertEquals("rt-42", dTag) assertEquals("rt-42", dTag)
val roomTag = template.tags.firstOrNull { it.firstOrNull() == "room" }?.getOrNull(1) // The rebuilt template emits the canonical `title` tag (per the
assertEquals("New name", roomTag) // deployed nostrnests reference); a legacy `room` tag from the
// source event MUST be dropped, not duplicated.
val titleTag = template.tags.firstOrNull { it.firstOrNull() == "title" }?.getOrNull(1)
assertEquals("New name", titleTag)
assertNull(template.tags.firstOrNull { it.firstOrNull() == "room" })
val summaryTag = template.tags.firstOrNull { it.firstOrNull() == "summary" }?.getOrNull(1) val summaryTag = template.tags.firstOrNull { it.firstOrNull() == "summary" }?.getOrNull(1)
assertEquals("New summary", summaryTag) assertEquals("New summary", summaryTag)
@@ -23,17 +23,24 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags
import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure import com.vitorpamplona.quartz.utils.ensure
/**
* Room display name on a NIP-53 / nests `kind:30312` event. Matches
* the deployed nostrnests reference, which writes `["title", name]`
* AND filters its lobby on the `title` tag's presence kind-30312
* events without a `title` are dropped from "Live Now". The early
* EGG-01 draft called this `room`; we accept that name on read for
* older events but always emit `title`.
*/
class RoomNameTag { class RoomNameTag {
companion object Companion { companion object Companion {
const val TAG_NAME = "room" const val TAG_NAME = "title"
/** /**
* Legacy alias used by first-generation nostrnests web clients, * Earlier EGG-01 draft name for the room display name.
* which reused the NIP-53 streaming-event `title` tag for the * Accepted on read for back-compat; we always emit the
* kind-30312 room name. Accepted on read; we always emit the
* canonical [TAG_NAME]. * canonical [TAG_NAME].
*/ */
const val LEGACY_TAG_NAME = "title" const val LEGACY_TAG_NAME = "room"
fun parse(tag: Array<String>): String? { fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null } ensure(tag.has(1)) { return null }
@@ -52,7 +52,7 @@ class MeetingSpaceEventBuildTest {
val byName = template.tags.groupBy { it[0] } val byName = template.tags.groupBy { it[0] }
assertEquals("main-hall", byName["d"]?.single()?.get(1)) assertEquals("main-hall", byName["d"]?.single()?.get(1))
assertEquals("Main Hall", byName["room"]?.single()?.get(1)) assertEquals("Main Hall", byName["title"]?.single()?.get(1))
assertEquals("live", byName["status"]?.single()?.get(1)) assertEquals("live", byName["status"]?.single()?.get(1))
assertEquals("https://meet.example.com/hall", byName["auth"]?.single()?.get(1)) assertEquals("https://meet.example.com/hall", byName["auth"]?.single()?.get(1))
assertEquals("https://api.example.com/hall", byName["streaming"]?.single()?.get(1)) assertEquals("https://api.example.com/hall", byName["streaming"]?.single()?.get(1))