feat(audio-rooms): pure builders for promote / demote (T1 #5)

Adds the host-side participant-list mutations as pure functions —
extracted to make every behaviour unit-testable without an
AccountViewModel / signer:

  RoomParticipantActions.setRole(original, target, newRole)
  RoomParticipantActions.demoteToListener(original, target)

Both republish kind-30312 with the same `d`-tag so the relay
treats it as a replacement of the original. Internally:
  * If the target was already a participant, their tag is updated
    in place — no duplicate `p`-rows.
  * If they weren't (audience member promoting up from the room),
    a fresh `p`-tag is appended.
  * The host can NEVER be demoted — there's exactly one host per
    NIP-53 audio room and the host's pubkey IS the event author,
    so flipping their role would produce a corrupt event. Returns
    null instead.
  * Every other promoted participant is preserved verbatim — same
    risk-mitigation as EditAudioRoomViewModel.buildEditTemplate.

6 unit tests covering:
  * Promote-when-absent appends with the requested role
  * Promote-when-present updates in place (no duplicate row)
  * Host can't be demoted
  * Demote flips a speaker to PARTICIPANT, leaves moderators alone
  * Republish keeps the same `d`-tag
  * Promoting one speaker doesn't disturb the other speakers

Sheet/menu wiring + the hand-raise queue UI come next.
This commit is contained in:
Claude
2026-04-26 22:40:37 +00:00
parent 9fa6f756ca
commit 6d1299c72a
2 changed files with 242 additions and 0 deletions
@@ -0,0 +1,115 @@
/*
* 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.ui.screen.loggedIn.audiorooms.room
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.endpoint
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.image
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.participants
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.summary
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.tags.StatusTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
/**
* Pure builders for participant-list mutations on a kind-30312
* audio room. All three return a republish template with the SAME
* `d`-tag — the relay treats it as a replacement of [original].
*
* Extracted out of the screen Composable so they can be unit-tested
* without an AccountViewModel / signer (the sign+broadcast path is
* the only side effect, mirroring [EditAudioRoomViewModel.buildEditTemplate]).
*
* Risk-mitigation: each builder reads ALL participants from
* [original] and rebuilds the list with one row mutated; never
* passes a smaller list. A future "remove ghost participants"
* refactor needs to live elsewhere — these builders never
* silently drop anyone.
*/
internal object RoomParticipantActions {
/**
* Promote [targetPubkey] to [newRole]. If they were already a
* participant on the event, their existing tag is replaced; if
* not (e.g. an audience member raising their hand), a new
* `p`-tag is added at the bottom of the list.
*
* Refuses to demote the host — there's exactly one host per
* NIP-53 audio room and the host's pubkey IS the event author,
* so demoting them produces a corrupt event. Returns null in
* that case.
*/
fun setRole(
original: MeetingSpaceEvent,
targetPubkey: String,
newRole: ROLE,
): EventTemplate<MeetingSpaceEvent>? {
val all = original.participants()
val targetExisting = all.firstOrNull { it.pubKey == targetPubkey }
if (targetExisting?.role.equals(ROLE.HOST.code, ignoreCase = true) && newRole != ROLE.HOST) {
return null
}
val mutated =
if (targetExisting != null) {
all.map {
if (it.pubKey == targetPubkey) it.copy(role = newRole.code) else it
}
} else {
all + ParticipantTag(targetPubkey, null, newRole.code, null)
}
return rebuild(original, mutated, original.status() ?: StatusTag.STATUS.OPEN)
}
/**
* Demote a speaker / moderator back to listener. Same guarantees
* as [setRole] — host can't be demoted; absent target is a no-op
* (the row just disappears from the participant list).
*/
fun demoteToListener(
original: MeetingSpaceEvent,
targetPubkey: String,
): EventTemplate<MeetingSpaceEvent>? = setRole(original, targetPubkey, ROLE.PARTICIPANT)
private fun rebuild(
original: MeetingSpaceEvent,
participants: List<ParticipantTag>,
status: StatusTag.STATUS,
): EventTemplate<MeetingSpaceEvent> {
val host =
participants.firstOrNull { it.role.equals(ROLE.HOST.code, ignoreCase = true) }
?: ParticipantTag(original.pubKey, null, ROLE.HOST.code, null)
val others = participants.filterNot { it.pubKey == host.pubKey }
return MeetingSpaceEvent.build(
room = original.room().orEmpty(),
status = status,
service = original.service().orEmpty(),
host = host,
dTag = original.dTag(),
) {
original.endpoint()?.let { endpoint(it) }
original.summary()?.takeIf { it.isNotBlank() }?.let { summary(it) }
original.image()?.takeIf { it.isNotBlank() }?.let { image(it) }
if (others.isNotEmpty()) participants(others)
}
}
}
@@ -0,0 +1,127 @@
/*
* 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.ui.screen.loggedIn.audiorooms.room
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class RoomParticipantActionsTest {
private val host = "a".repeat(64)
private val alice = "b".repeat(64)
private val bob = "c".repeat(64)
private fun room(extra: List<Array<String>> = emptyList()): MeetingSpaceEvent =
MeetingSpaceEvent(
id = "0".repeat(64),
pubKey = host,
createdAt = 100L,
tags =
(
listOf(
arrayOf("d", "rt-1"),
arrayOf("room", "Room"),
arrayOf("status", "open"),
arrayOf("service", "https://moq"),
arrayOf("endpoint", "https://moq"),
arrayOf("p", host, "", "host"),
) + extra
).toTypedArray(),
content = "",
sig = "0".repeat(128),
)
@Test
fun promoteAddsParticipantWithSpeakerRoleWhenAbsent() {
val original = room()
val template = RoomParticipantActions.setRole(original, alice, ROLE.SPEAKER)
assertNotNull(template)
val pTags = template!!.tags.filter { it.firstOrNull() == "p" }
// Host plus the new speaker — exactly one entry per pubkey.
assertEquals(2, pTags.size)
val aliceTag = pTags.firstOrNull { it.getOrNull(1) == alice }
assertNotNull(aliceTag)
assertEquals(ROLE.SPEAKER.code, aliceTag!!.getOrNull(3))
}
@Test
fun promoteUpdatesExistingRoleInPlace() {
val original = room(listOf(arrayOf("p", alice, "", ROLE.PARTICIPANT.code)))
val template = RoomParticipantActions.setRole(original, alice, ROLE.SPEAKER)
assertNotNull(template)
val pTags = template!!.tags.filter { it.firstOrNull() == "p" }
// Still 2 rows — alice's row is updated, not duplicated.
assertEquals(2, pTags.size)
val aliceTag = pTags.first { it.getOrNull(1) == alice }
assertEquals(ROLE.SPEAKER.code, aliceTag.getOrNull(3))
}
@Test
fun cannotDemoteHost() {
val original = room()
// Trying to flip the host to PARTICIPANT must fail — there's
// exactly one host per audio room, and demoting them produces
// an unauthored event.
assertNull(RoomParticipantActions.setRole(original, host, ROLE.PARTICIPANT))
assertNull(RoomParticipantActions.demoteToListener(original, host))
}
@Test
fun demoteFlipsSpeakerToParticipant() {
val original =
room(
listOf(
arrayOf("p", alice, "", ROLE.SPEAKER.code),
arrayOf("p", bob, "", ROLE.MODERATOR.code),
),
)
val template = RoomParticipantActions.demoteToListener(original, alice)
assertNotNull(template)
val aliceTag = template!!.tags.first { it.firstOrNull() == "p" && it.getOrNull(1) == alice }
assertEquals(ROLE.PARTICIPANT.code, aliceTag.getOrNull(3))
// Other roles are untouched.
val bobTag = template.tags.first { it.firstOrNull() == "p" && it.getOrNull(1) == bob }
assertEquals(ROLE.MODERATOR.code, bobTag.getOrNull(3))
}
@Test
fun republishKeepsSameDTag() {
val original = room()
val template = RoomParticipantActions.setRole(original, alice, ROLE.SPEAKER)!!
val dTag = template.tags.first { it.firstOrNull() == "d" }.getOrNull(1)
assertEquals(original.dTag(), dTag)
}
@Test
fun promotingNewSpeakerLeavesOtherSpeakersAlone() {
val original = room(listOf(arrayOf("p", bob, "", ROLE.SPEAKER.code)))
val template = RoomParticipantActions.setRole(original, alice, ROLE.SPEAKER)!!
val pTags = template.tags.filter { it.firstOrNull() == "p" }
assertEquals(3, pTags.size)
assertTrue(pTags.any { it.getOrNull(1) == host })
assertTrue(pTags.any { it.getOrNull(1) == alice })
assertTrue(pTags.any { it.getOrNull(1) == bob })
}
}