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