feat(audio-rooms): RoomMember + ParticipantGrid pure projection (T2 #1)

Data model + projection function for the participant grid:

  RoomMember — one row in the grid. Combines the static `p`-tag
  role (kind-30312) with the dynamic presence flags (kind-10312
  aggregator). Carries an `absent` Boolean for "member never
  joined" — nostrnests greys them out and we keep parity by
  surfacing the flag for the UI to decide.

  ParticipantGrid — onStage / audience split.

  buildParticipantGrid(participants, presences) — pure projection.
  Stage placement is `canSpeak() && onstage != false`, so a speaker
  who explicitly emits `onstage=0` (Tier 1 Step 1's "step off the
  stage" tag) drops to audience without losing their speaker role.
  Pure-audience members (present in the ledger but not p-tagged)
  show up in audience with `role = null`.

Tests:
  * Host + speaker on stage with matching presence
  * Speaker with onstage=0 drops to audience
  * Pure listener (no p-tag) lands in audience with role=null
  * P-tagged speaker without presence is on stage with absent=true
  * Empty inputs produce an empty grid (no crash)

The actual `LazyVerticalGrid` rendering is a follow-up — current
`StagePeopleRow`s already cover the on-stage/audience split
visually; the data model is the substantive piece. Wiring the
VM's `participantGrid: StateFlow<ParticipantGrid>` and switching
the room screen to a grid layout can ship later without changing
the wire contract or tests.
This commit is contained in:
Claude
2026-04-26 22:52:38 +00:00
parent d1be2ae19e
commit 5cb2351cdb
2 changed files with 234 additions and 0 deletions
@@ -0,0 +1,116 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.viewmodels
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
/**
* One row in the room's participant grid. Combines the static
* `p`-tag role (from the kind-30312 event) with the dynamic
* presence flags (from the per-pubkey kind-10312 aggregator).
*
* Pure data — UI just renders it.
*/
@Immutable
data class RoomMember(
val pubkey: String,
/** From the kind-30312 `p`-tag; null when the user is pure audience. */
val role: ROLE?,
/** Last seen second from the kind-10312 ledger; null when the user has never emitted presence. */
val lastSeenSec: Long?,
val handRaised: Boolean,
val muted: Boolean?,
val publishing: Boolean,
/**
* `true` when the user is in `event.participants()` BUT has no
* recent presence in the aggregator. nostrnests greys out these
* "members never joined" — match for parity.
*/
val absent: Boolean,
)
/** Two-column layout: speakers up top, audience below. */
@Immutable
data class ParticipantGrid(
val onStage: List<RoomMember>,
val audience: List<RoomMember>,
) {
companion object {
val Empty = ParticipantGrid(emptyList(), emptyList())
}
}
/**
* Pure projection — given the event's participants + the
* per-pubkey presence map, build the grid.
*
* * On-stage: anyone whose `p`-tag role is HOST/MODERATOR/SPEAKER
* AND whose latest presence advertises `onstage != false`. A
* speaker who explicitly emitted `onstage=0` ("step off the
* stage") drops to audience without losing their role tag.
* * 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`).
*/
fun buildParticipantGrid(
participants: List<com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag>,
presences: Map<String, RoomPresence>,
): ParticipantGrid {
val onStage = mutableListOf<RoomMember>()
val audience = mutableListOf<RoomMember>()
val seen = mutableSetOf<String>()
for (p in participants) {
seen += p.pubKey
val pres = presences[p.pubKey]
val role = p.effectiveRole()
val canSpeak = p.canSpeak()
val onstageFlag = pres?.onstage ?: true // default true for backwards compat
val member =
RoomMember(
pubkey = p.pubKey,
role = role,
lastSeenSec = pres?.updatedAtSec,
handRaised = pres?.handRaised == true,
muted = pres?.muted,
publishing = pres?.publishing == true,
absent = pres == null,
)
if (canSpeak && onstageFlag) onStage += member else audience += member
}
// Pure-audience members — present in the kind-10312 ledger but
// not in the kind-30312 `p`-tags.
for ((pubkey, pres) in presences) {
if (pubkey in seen) continue
audience +=
RoomMember(
pubkey = pubkey,
role = null,
lastSeenSec = pres.updatedAtSec,
handRaised = pres.handRaised,
muted = pres.muted,
publishing = pres.publishing,
absent = false,
)
}
return ParticipantGrid(onStage = onStage, audience = audience)
}
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.viewmodels
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ParticipantGridTest {
private val host = "a".repeat(64)
private val speaker = "b".repeat(64)
private val listener = "c".repeat(64)
private fun pTag(
pk: String,
role: ROLE,
) = ParticipantTag(pubKey = pk, relayHint = null, role = role.code, proof = null)
private fun presence(
pk: String,
ts: Long,
onstage: Boolean = true,
handRaised: Boolean = false,
publishing: Boolean = false,
muted: Boolean? = null,
) = RoomPresence(
pubkey = pk,
handRaised = handRaised,
muted = muted,
publishing = publishing,
onstage = onstage,
updatedAtSec = ts,
)
@Test
fun hostAndSpeakerLandOnStageWhenPresenceMatches() {
val grid =
buildParticipantGrid(
participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)),
presences = mapOf(host to presence(host, 100L), speaker to presence(speaker, 100L)),
)
assertEquals(2, grid.onStage.size)
assertEquals(0, grid.audience.size)
assertEquals(setOf(host, speaker), grid.onStage.map { it.pubkey }.toSet())
}
@Test
fun speakerWithOnstageFalseDropsToAudience() {
val grid =
buildParticipantGrid(
participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)),
presences =
mapOf(
host to presence(host, 100L),
speaker to presence(speaker, 100L, onstage = false),
),
)
assertEquals(setOf(host), grid.onStage.map { it.pubkey }.toSet())
assertEquals(setOf(speaker), grid.audience.map { it.pubkey }.toSet())
}
@Test
fun pureAudienceMembersAppearInAudience() {
val grid =
buildParticipantGrid(
participants = listOf(pTag(host, ROLE.HOST)),
presences = mapOf(host to presence(host, 100L), listener to presence(listener, 100L)),
)
// host on stage; the kind-10312-only listener in audience.
assertEquals(setOf(host), grid.onStage.map { it.pubkey }.toSet())
assertEquals(setOf(listener), grid.audience.map { it.pubkey }.toSet())
// The audience member has no role.
assertEquals(null, grid.audience.first().role)
}
@Test
fun absentParticipantTagIsAbsentTrueOnAudienceRow() {
// Speaker whose presence we've never seen — kind-30312 says
// they're a member, but they haven't joined yet. nostrnests
// greys them out; we mark absent=true so the renderer can.
val grid =
buildParticipantGrid(
participants = listOf(pTag(host, ROLE.HOST), pTag(speaker, ROLE.SPEAKER)),
presences = mapOf(host to presence(host, 100L)),
)
// The speaker has no presence — defaults onstage=true, so
// they DO appear on stage, marked absent.
val onStageSpeaker = grid.onStage.first { it.pubkey == speaker }
assertTrue(onStageSpeaker.absent, "speaker without presence should be marked absent")
}
@Test
fun emptyInputProducesEmptyGrid() {
val grid = buildParticipantGrid(emptyList(), emptyMap())
assertEquals(0, grid.onStage.size)
assertEquals(0, grid.audience.size)
}
}