diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomPresencePublisher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomPresencePublisher.kt index 54de9d138..e148e3d0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomPresencePublisher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/lifecycle/NestRoomPresencePublisher.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lifecycle import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.NestUiState import com.vitorpamplona.amethyst.model.Account @@ -43,9 +45,12 @@ import kotlinx.coroutines.launch * onstage transition, then every [PRESENCE_REFRESH_MS]. Mute * and publishing flags are intentionally NOT keys: every mute * toggle would otherwise round-trip a presence emit (audit - * Android #11). The next heartbeat picks them up within 30 s, - * which is well within the user's "did the peer see my mute" - * tolerance. + * Android #11). The heartbeat reads them via + * [rememberUpdatedState] so each refresh picks up the latest + * 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 * [PRESENCE_DEBOUNCE_MS] for further changes before sending a @@ -77,11 +82,21 @@ internal fun NestPresencePublisher( val publishingTag: Boolean = ui.publishingNow 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) { - publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag) + publishPresence(account, event, currentHandRaised, currentMicMuted, currentPublishing, currentOnstage) while (isActive) { delay(PRESENCE_REFRESH_MS) - publishPresence(account, event, handRaised, micMutedTag, publishingTag, onstageTag) + publishPresence(account, event, currentHandRaised, currentMicMuted, currentPublishing, currentOnstage) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt index c38cae46d..f747a500e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/ParticipantsGrid.kt @@ -355,10 +355,15 @@ private fun MemberCell( // Both color and width crossfade so going idle → speaking → idle // doesn't snap; the color animates from Transparent through the // 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 = when { isSpeaking -> NEST_SPEAKING_COLOR - showMicBadge && member.publishing && member.muted == true -> mutedRingColor + showMicBadge && member.muted == true -> mutedRingColor else -> Color.Transparent } val targetRingWidth = @@ -445,7 +450,13 @@ private fun MemberCell( 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( isSpeaking = isSpeaking, isMuted = member.muted == true, diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt index 46db127bc..a14ef80fc 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/edit/EditNestViewModelTest.kt @@ -91,8 +91,12 @@ class EditNestViewModelTest { val dTag = template.tags.firstOrNull { it.firstOrNull() == "d" }?.getOrNull(1) assertEquals("rt-42", dTag) - val roomTag = template.tags.firstOrNull { it.firstOrNull() == "room" }?.getOrNull(1) - assertEquals("New name", roomTag) + // The rebuilt template emits the canonical `title` tag (per the + // 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) assertEquals("New summary", summaryTag) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt index 3a4b03179..0d21a9adb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/tags/RoomNameTag.kt @@ -23,17 +23,24 @@ package com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags import com.vitorpamplona.quartz.nip01Core.core.has 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 { companion object Companion { - const val TAG_NAME = "room" + const val TAG_NAME = "title" /** - * Legacy alias used by first-generation nostrnests web clients, - * which reused the NIP-53 streaming-event `title` tag for the - * kind-30312 room name. Accepted on read; we always emit the + * Earlier EGG-01 draft name for the room display name. + * Accepted on read for back-compat; we always emit the * canonical [TAG_NAME]. */ - const val LEGACY_TAG_NAME = "title" + const val LEGACY_TAG_NAME = "room" fun parse(tag: Array): String? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt index 540de9256..1b643f5ba 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip53LiveActivities/meetingSpaces/MeetingSpaceEventBuildTest.kt @@ -52,7 +52,7 @@ class MeetingSpaceEventBuildTest { val byName = template.tags.groupBy { it[0] } 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("https://meet.example.com/hall", byName["auth"]?.single()?.get(1)) assertEquals("https://api.example.com/hall", byName["streaming"]?.single()?.get(1))