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 new file mode 100644 index 000000000..838fc6fbb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGrid.kt @@ -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, + val audience: List, +) { + 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, + presences: Map, +): ParticipantGrid { + val onStage = mutableListOf() + val audience = mutableListOf() + + val seen = mutableSetOf() + 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) +} 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 new file mode 100644 index 000000000..a5d71039e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ParticipantGridTest.kt @@ -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) + } +}