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