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