From 20a2270434189eb6d36b560c8471cc2e63c7112a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 13 May 2026 14:57:52 -0400 Subject: [PATCH] fix nests room author shown in audience with no audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nostrnests publishes kind-30312 without a self-`p`-tag for the room author — they're the implicit host. The lobby card already handles this (NestJoinCard) but the in-room code did not: the author fell into the pure-audience branch with role=null AND was missing from the audio subscription set, so the host appeared as a regular listener and no sound played. buildParticipantGrid now takes hostPubkey and synthesizes a virtual `["p", host, "", "host"]` when absent, so the existing presence-aware onstage/audience rules apply uniformly — including "host leaves stage" (onstage=0 → drops to audience, role stays HOST). NestActivityContent owns the grid now and derives onStageKeys from it, so the StageGrid render and the MoQ subscription set stay in lockstep when anyone (host or speaker) steps off stage. --- .../room/activity/NestActivityContent.kt | 40 ++++++----- .../nests/room/screen/NestFullScreen.kt | 17 ++--- .../nests/room/screen/NestPipScreen.kt | 14 ++-- .../commons/viewmodels/ParticipantGrid.kt | 23 ++++++- .../commons/viewmodels/ParticipantGridTest.kt | 66 +++++++++++++++++++ 5 files changed, 124 insertions(+), 36 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt index d5d9aa8dd..5c5a95970 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel +import com.vitorpamplona.amethyst.commons.viewmodels.buildParticipantGrid import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.call.KeepScreenOn import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote @@ -66,7 +67,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.nestsclient.NestsRoomConfig import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE import kotlinx.coroutines.flow.SharedFlow /** @@ -233,18 +233,28 @@ private fun NestActivityBody( val localPubkey = account.signer.pubKey val roomATag = roomNote.idHex - // Static room layout: hosts + speakers from the kind-30312 `p`-tags. - // The grid renderer derives audience from the kind-10312 presence - // aggregator (see ParticipantsGrid / buildParticipantGrid); this - // composable only needs the on-stage subset for the talk-row gate - // and the PIP renderer. - val onStage = - remember(event) { - event.participants().filter { - it.role.equals(ROLE.HOST.code, true) || it.role.equals(ROLE.SPEAKER.code, true) - } + val ui by viewModel.uiState.collectAsState() + val presences by viewModel.presences.collectAsState() + + // Single source of truth for "who is on the stage". Built from the + // kind-30312 `p`-tags + the kind-10312 presence aggregator, with + // the event author synthesised as the implicit HOST when they're + // not in their own `p`-tags (per EGG-07; nostrnests' default). + // This drives BOTH the StageGrid render AND the audio subscription + // set, so the two stay in lockstep when a speaker (or the host) + // emits `onstage=0` to step off the stage. + val participantGrid = + remember(event, presences) { + buildParticipantGrid( + participants = event.participants(), + presences = presences, + hostPubkey = event.pubKey, + ) + } + val onStageKeys = + remember(participantGrid) { + participantGrid.onStage.map { it.pubkey }.toSet() } - val onStageKeys = remember(onStage) { onStage.map { it.pubKey }.toSet() } AutoConnectAndTrackSpeakers(viewModel, onStageKeys) @@ -264,8 +274,6 @@ private fun NestActivityBody( // listener + speaker when the activity finishes. LeaveOnRoomClosed(event, onLeave) - val ui by viewModel.uiState.collectAsState() - // Hold a screen-on lock while the user is actively in the room so the // device doesn't lock and interrupt the audio session UI. if (ui.connection is ConnectionUiState.Connected) { @@ -310,7 +318,7 @@ private fun NestActivityBody( if (isInPipMode) { NestPipScreen( title = event.room(), - onStage = onStage, + onStage = participantGrid.onStage, ui = ui, viewModel = viewModel, handRaised = handRaised, @@ -320,7 +328,7 @@ private fun NestActivityBody( NestFullScreen( event = event, roomNote = roomNote, - onStage = onStage, + participantGrid = participantGrid, viewModel = viewModel, ui = ui, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt index 9d2eebc40..464b5ee92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestFullScreen.kt @@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel -import com.vitorpamplona.amethyst.commons.viewmodels.buildParticipantGrid +import com.vitorpamplona.amethyst.commons.viewmodels.ParticipantGrid import com.vitorpamplona.amethyst.ui.navigation.topbars.ShorterTopAppBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestChatPanel @@ -79,7 +79,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip19Bech32.toNAddr import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import kotlinx.coroutines.launch /** @@ -108,7 +107,7 @@ import kotlinx.coroutines.launch internal fun NestFullScreen( event: MeetingSpaceEvent, roomNote: com.vitorpamplona.amethyst.model.AddressableNote, - onStage: List, + participantGrid: ParticipantGrid, viewModel: NestViewModel, ui: NestUiState, accountViewModel: AccountViewModel, @@ -143,14 +142,6 @@ internal fun NestFullScreen( val speakerCatalogs by viewModel.speakerCatalogs.collectAsState() val audioLevels by viewModel.audioLevels.collectAsState() - val onStageKeys = remember(onStage) { onStage.map { it.pubKey }.toSet() } - val participantGrid = - remember(event, presences) { - buildParticipantGrid( - participants = event.participants(), - presences = presences, - ) - } // Tie the action bar's "am I on stage" gate to the same data // source the StageGrid uses (role + presence.onstage flag), // not just the kind-30312 role tag. Otherwise a host who taps @@ -162,6 +153,10 @@ internal fun NestFullScreen( remember(participantGrid, myPubkey) { participantGrid.onStage.any { it.pubkey == myPubkey } } + val onStageKeys = + remember(participantGrid) { + participantGrid.onStage.map { it.pubkey }.toSet() + } // Same logic HandRaiseQueueSection uses internally — duplicated // here so the tab label can show a count without coupling the // section to the screen. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestPipScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestPipScreen.kt index d3df7c75e..94c215cf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestPipScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestPipScreen.kt @@ -53,10 +53,10 @@ import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel +import com.vitorpamplona.amethyst.commons.viewmodels.RoomMember import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag /** * Compact PIP layout. Renders three things, in priority order: @@ -84,7 +84,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTa @Composable internal fun NestPipScreen( title: String?, - onStage: List, + onStage: List, ui: NestUiState, viewModel: NestViewModel, handRaised: Boolean, @@ -97,7 +97,7 @@ internal fun NestPipScreen( // with the speakingNow set and produce ghost avatars. val onStageNowSpeaking = remember(onStage, ui.speakingNow) { - onStage.filter { it.pubKey in ui.speakingNow } + onStage.filter { it.pubkey in ui.speakingNow } } val focused = if (onStageNowSpeaking.isNotEmpty()) { @@ -143,11 +143,11 @@ internal fun NestPipScreen( ) { focused.forEach { participant -> PipAvatar( - pubkey = participant.pubKey, + pubkey = participant.pubkey, size = avatarSize, - isSpeaking = participant.pubKey in ui.speakingNow, - isConnecting = participant.pubKey in ui.connectingSpeakers, - audioLevel = audioLevels[participant.pubKey] ?: 0f, + isSpeaking = participant.pubkey in ui.speakingNow, + isConnecting = participant.pubkey in ui.connectingSpeakers, + audioLevel = audioLevels[participant.pubkey] ?: 0f, accountViewModel = accountViewModel, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt index 838fc6fbb..877fb2f23 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.commons.viewmodels import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE /** @@ -70,16 +72,33 @@ data class ParticipantGrid( * * 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`). + * + * [hostPubkey] is the kind-30312 event signer. Per the EGG-07 + * convention (and nostrnests' production behaviour), the room + * author is the implicit host even when they don't self-tag. + * When the author isn't already in [participants], a synthetic + * `["p", hostPubkey, "", "host"]` tag is prepended so the + * presence-aware on-stage/audience rules apply uniformly — + * including the "host stepped off stage" case (presence with + * `onstage=0` → drops to audience, role stays HOST). */ fun buildParticipantGrid( - participants: List, + participants: List, presences: Map, + hostPubkey: HexKey? = null, ): ParticipantGrid { + val effectiveParticipants = + if (hostPubkey != null && participants.none { it.pubKey == hostPubkey }) { + listOf(ParticipantTag(hostPubkey, null, ROLE.HOST.code, null)) + participants + } else { + participants + } + val onStage = mutableListOf() val audience = mutableListOf() val seen = mutableSetOf() - for (p in participants) { + for (p in effectiveParticipants) { seen += p.pubKey val pres = presences[p.pubKey] val role = p.effectiveRole() diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt index a5d71039e..2a5ff4bcb 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt @@ -115,4 +115,70 @@ class ParticipantGridTest { assertEquals(0, grid.onStage.size) assertEquals(0, grid.audience.size) } + + @Test + fun implicitHostNotInPTagsLandsOnStage() { + // nostrnests publishes kind-30312 with no self-`p`-tag for + // the room author — the author is the implicit host. Without + // synthesizing them, they fell through into the pure-audience + // branch (role=null) AND were left out of the audio + // subscription set, producing "host shown in audience + no + // sound" — the exact regression this case exists to catch. + val grid = + buildParticipantGrid( + participants = listOf(pTag(speaker, ROLE.SPEAKER)), + presences = emptyMap(), + hostPubkey = host, + ) + val hostRow = grid.onStage.firstOrNull { it.pubkey == host } + assertEquals(ROLE.HOST, hostRow?.role) + // No presence yet — host shows up greyed-out, matching how + // p-tagged speakers without presence already render. + assertEquals(true, hostRow?.absent) + assertTrue(grid.audience.none { it.pubkey == host }) + } + + @Test + fun implicitHostWithOnstageTrueStaysOnStage() { + val grid = + buildParticipantGrid( + participants = listOf(pTag(speaker, ROLE.SPEAKER)), + presences = mapOf(host to presence(host, 100L, onstage = true)), + hostPubkey = host, + ) + val hostRow = grid.onStage.firstOrNull { it.pubkey == host } + assertEquals(ROLE.HOST, hostRow?.role) + assertEquals(false, hostRow?.absent) + } + + @Test + fun implicitHostLeavesStageDropsToAudienceKeepingHostRole() { + // The host taps "Leave Stage" on nostrnests → kind-10312 with + // onstage=0. They should drop to audience but the host crown + // stays — exact same behaviour as an explicit speaker stepping + // off the stage. + val grid = + buildParticipantGrid( + participants = listOf(pTag(speaker, ROLE.SPEAKER)), + presences = mapOf(host to presence(host, 100L, onstage = false)), + hostPubkey = host, + ) + assertTrue(grid.onStage.none { it.pubkey == host }) + val hostRow = grid.audience.firstOrNull { it.pubkey == host } + assertEquals(ROLE.HOST, hostRow?.role) + } + + @Test + fun explicitHostInPTagsIsNotDuplicated() { + // The author already tagged themselves with role=host. The + // synthesized entry must NOT shadow / duplicate them. + val grid = + buildParticipantGrid( + participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)), + presences = mapOf(host to presence(host, 100L)), + hostPubkey = host, + ) + assertEquals(2, grid.onStage.size) + assertEquals(1, grid.onStage.count { it.pubkey == host }) + } }