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:
+116
@@ -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)
|
||||
}
|
||||
+118
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user