fix nests room author shown in audience with no audio

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.
This commit is contained in:
Vitor Pamplona
2026-05-13 14:57:52 -04:00
parent e7dc8ec25c
commit 20a2270434
5 changed files with 124 additions and 36 deletions
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel 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.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.call.KeepScreenOn import com.vitorpamplona.amethyst.ui.call.KeepScreenOn
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote 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.nestsclient.NestsRoomConfig
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
/** /**
@@ -233,18 +233,28 @@ private fun NestActivityBody(
val localPubkey = account.signer.pubKey val localPubkey = account.signer.pubKey
val roomATag = roomNote.idHex val roomATag = roomNote.idHex
// Static room layout: hosts + speakers from the kind-30312 `p`-tags. val ui by viewModel.uiState.collectAsState()
// The grid renderer derives audience from the kind-10312 presence val presences by viewModel.presences.collectAsState()
// aggregator (see ParticipantsGrid / buildParticipantGrid); this
// composable only needs the on-stage subset for the talk-row gate // Single source of truth for "who is on the stage". Built from the
// and the PIP renderer. // kind-30312 `p`-tags + the kind-10312 presence aggregator, with
val onStage = // the event author synthesised as the implicit HOST when they're
remember(event) { // not in their own `p`-tags (per EGG-07; nostrnests' default).
event.participants().filter { // This drives BOTH the StageGrid render AND the audio subscription
it.role.equals(ROLE.HOST.code, true) || it.role.equals(ROLE.SPEAKER.code, true) // 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) AutoConnectAndTrackSpeakers(viewModel, onStageKeys)
@@ -264,8 +274,6 @@ private fun NestActivityBody(
// listener + speaker when the activity finishes. // listener + speaker when the activity finishes.
LeaveOnRoomClosed(event, onLeave) LeaveOnRoomClosed(event, onLeave)
val ui by viewModel.uiState.collectAsState()
// Hold a screen-on lock while the user is actively in the room so the // 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. // device doesn't lock and interrupt the audio session UI.
if (ui.connection is ConnectionUiState.Connected) { if (ui.connection is ConnectionUiState.Connected) {
@@ -310,7 +318,7 @@ private fun NestActivityBody(
if (isInPipMode) { if (isInPipMode) {
NestPipScreen( NestPipScreen(
title = event.room(), title = event.room(),
onStage = onStage, onStage = participantGrid.onStage,
ui = ui, ui = ui,
viewModel = viewModel, viewModel = viewModel,
handRaised = handRaised, handRaised = handRaised,
@@ -320,7 +328,7 @@ private fun NestActivityBody(
NestFullScreen( NestFullScreen(
event = event, event = event,
roomNote = roomNote, roomNote = roomNote,
onStage = onStage, participantGrid = participantGrid,
viewModel = viewModel, viewModel = viewModel,
ui = ui, ui = ui,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
@@ -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.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel 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.navigation.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.chat.NestChatPanel 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.nip19Bech32.toNAddr
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -108,7 +107,7 @@ import kotlinx.coroutines.launch
internal fun NestFullScreen( internal fun NestFullScreen(
event: MeetingSpaceEvent, event: MeetingSpaceEvent,
roomNote: com.vitorpamplona.amethyst.model.AddressableNote, roomNote: com.vitorpamplona.amethyst.model.AddressableNote,
onStage: List<ParticipantTag>, participantGrid: ParticipantGrid,
viewModel: NestViewModel, viewModel: NestViewModel,
ui: NestUiState, ui: NestUiState,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -143,14 +142,6 @@ internal fun NestFullScreen(
val speakerCatalogs by viewModel.speakerCatalogs.collectAsState() val speakerCatalogs by viewModel.speakerCatalogs.collectAsState()
val audioLevels by viewModel.audioLevels.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 // Tie the action bar's "am I on stage" gate to the same data
// source the StageGrid uses (role + presence.onstage flag), // source the StageGrid uses (role + presence.onstage flag),
// not just the kind-30312 role tag. Otherwise a host who taps // not just the kind-30312 role tag. Otherwise a host who taps
@@ -162,6 +153,10 @@ internal fun NestFullScreen(
remember(participantGrid, myPubkey) { remember(participantGrid, myPubkey) {
participantGrid.onStage.any { it.pubkey == myPubkey } participantGrid.onStage.any { it.pubkey == myPubkey }
} }
val onStageKeys =
remember(participantGrid) {
participantGrid.onStage.map { it.pubkey }.toSet()
}
// Same logic HandRaiseQueueSection uses internally — duplicated // Same logic HandRaiseQueueSection uses internally — duplicated
// here so the tab label can show a count without coupling the // here so the tab label can show a count without coupling the
// section to the screen. // section to the screen.
@@ -53,10 +53,10 @@ import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestUiState
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel 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.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
/** /**
* Compact PIP layout. Renders three things, in priority order: * Compact PIP layout. Renders three things, in priority order:
@@ -84,7 +84,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTa
@Composable @Composable
internal fun NestPipScreen( internal fun NestPipScreen(
title: String?, title: String?,
onStage: List<ParticipantTag>, onStage: List<RoomMember>,
ui: NestUiState, ui: NestUiState,
viewModel: NestViewModel, viewModel: NestViewModel,
handRaised: Boolean, handRaised: Boolean,
@@ -97,7 +97,7 @@ internal fun NestPipScreen(
// with the speakingNow set and produce ghost avatars. // with the speakingNow set and produce ghost avatars.
val onStageNowSpeaking = val onStageNowSpeaking =
remember(onStage, ui.speakingNow) { remember(onStage, ui.speakingNow) {
onStage.filter { it.pubKey in ui.speakingNow } onStage.filter { it.pubkey in ui.speakingNow }
} }
val focused = val focused =
if (onStageNowSpeaking.isNotEmpty()) { if (onStageNowSpeaking.isNotEmpty()) {
@@ -143,11 +143,11 @@ internal fun NestPipScreen(
) { ) {
focused.forEach { participant -> focused.forEach { participant ->
PipAvatar( PipAvatar(
pubkey = participant.pubKey, pubkey = participant.pubkey,
size = avatarSize, size = avatarSize,
isSpeaking = participant.pubKey in ui.speakingNow, isSpeaking = participant.pubkey in ui.speakingNow,
isConnecting = participant.pubKey in ui.connectingSpeakers, isConnecting = participant.pubkey in ui.connectingSpeakers,
audioLevel = audioLevels[participant.pubKey] ?: 0f, audioLevel = audioLevels[participant.pubkey] ?: 0f,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
} }
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.commons.viewmodels package com.vitorpamplona.amethyst.commons.viewmodels
import androidx.compose.runtime.Immutable 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 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 * * Audience: every pubkey with recent presence that isn't on
* stage, plus participant-tagged users who haven't emitted * stage, plus participant-tagged users who haven't emitted
* presence yet (rendered as `absent = true`). * 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( fun buildParticipantGrid(
participants: List<com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag>, participants: List<ParticipantTag>,
presences: Map<String, RoomPresence>, presences: Map<String, RoomPresence>,
hostPubkey: HexKey? = null,
): ParticipantGrid { ): 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<RoomMember>() val onStage = mutableListOf<RoomMember>()
val audience = mutableListOf<RoomMember>() val audience = mutableListOf<RoomMember>()
val seen = mutableSetOf<String>() val seen = mutableSetOf<String>()
for (p in participants) { for (p in effectiveParticipants) {
seen += p.pubKey seen += p.pubKey
val pres = presences[p.pubKey] val pres = presences[p.pubKey]
val role = p.effectiveRole() val role = p.effectiveRole()
@@ -115,4 +115,70 @@ class ParticipantGridTest {
assertEquals(0, grid.onStage.size) assertEquals(0, grid.onStage.size)
assertEquals(0, grid.audience.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 })
}
} }