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:
+20
-5
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -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,
|
||||||
|
|||||||
+6
-2
@@ -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)
|
||||||
|
|||||||
+12
-5
@@ -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 }
|
||||||
|
|||||||
+1
-1
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user