feat(audio-rooms): kick + per-participant host actions (T1 #6)

End-to-end glue for the kick action and the broader per-participant
management surface:

  AudioRoomViewModel.wasKicked: StateFlow<Boolean>
  AudioRoomViewModel.onKick() — set-once flag, calls disconnect().
  Idempotent. Authority enforcement (signer must be host/moderator)
  is the platform layer's job — the relay does not enforce it.

  RoomAdminCommandsFilterAssembler — REQs `kinds=[4312], #a=[room],
  #p=[localPubkey]` so the relay only forwards commands actually
  targeting the local user.

  AudioRoomActivityContent — opens the wire sub on enter, observes
  LocalCache for new AdminCommandEvents, and gates each kick on the
  signer being either the room's pubkey OR a participant whose
  current role is host/moderator. Calls vm.onKick() on a valid
  match, then onLeave() once wasKicked flips.

  ParticipantHostActionsSheet — bottom sheet with three rows:
  Promote to speaker / Demote to listener / Kick. The destructive
  Kick row is colored error. Promote + Demote use
  RoomParticipantActions; Kick uses AdminCommandEvent.kick.

  StagePeopleRow + AudioRoomFullScreen — long-press an avatar
  (host-only, can't long-press the host themselves) opens the
  ParticipantHostActionsSheet for that target.

  RelaySubscriptionsCoordinator.roomAdminCommands registered
  alongside the other audio-room subs.

  Strings: audio_room_promote_speaker, audio_room_demote_listener,
           audio_room_kick_action.

Tests:
  * onKickFlipsWasKickedAndDisconnects — VM state transition
  * onKickIsIdempotent — no double-disconnect
This commit is contained in:
Claude
2026-04-26 22:49:47 +00:00
parent 7f5133a08f
commit d1be2ae19e
9 changed files with 354 additions and 0 deletions
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinder
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.AudioRoomsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomAdminCommandsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomChatFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomPresenceFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomReactionsFilterAssembler
@@ -113,6 +114,7 @@ class RelaySubscriptionsCoordinator(
val roomPresence = RoomPresenceFilterAssembler(client)
val roomChat = RoomChatFilterAssembler(client)
val roomReactions = RoomReactionsFilterAssembler(client)
val roomAdminCommands = RoomAdminCommandsFilterAssembler(client)
val longs = LongsFilterAssembler(client)
val articles = ArticlesFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
@@ -0,0 +1,109 @@
/*
* 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.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.audiorooms.admin.AdminCommandEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
/**
* Per-screen state for the per-room kind-4312 admin command sub.
* Filtered by both `#a` (the room) and `#p` (the local pubkey) so
* the relay only sends commands actually targeting the local user.
* Authority enforcement happens client-side in the room screen.
*/
@Stable
class RoomAdminCommandsQueryState(
val roomATag: String,
val localPubkey: String,
val account: Account,
)
class RoomAdminCommandsFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<RoomAdminCommandsQueryState>,
) : PerUniqueIdEoseManager<RoomAdminCommandsQueryState, String>(client, allKeys) {
override fun updateFilter(
key: RoomAdminCommandsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relays = key.account.outboxRelays.flow.value
return relays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(AdminCommandEvent.KIND),
tags = mapOf("a" to listOf(key.roomATag), "p" to listOf(key.localPubkey)),
since = since?.get(relay)?.time,
),
)
}
}
override fun id(key: RoomAdminCommandsQueryState): String = "${key.roomATag}|${key.localPubkey}"
}
@Stable
class RoomAdminCommandsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<RoomAdminCommandsQueryState>() {
private val sub = RoomAdminCommandsFilterSubAssembler(client, ::allKeys)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = sub.invalidateFilters()
override fun destroy() = sub.destroy()
}
@Composable
fun RoomAdminCommandsFilterAssemblerSubscription(
roomATag: String,
localPubkey: String,
accountViewModel: AccountViewModel,
) = RoomAdminCommandsFilterAssemblerSubscription(
roomATag,
localPubkey,
accountViewModel.account,
accountViewModel.dataSources().roomAdminCommands,
)
@Composable
fun RoomAdminCommandsFilterAssemblerSubscription(
roomATag: String,
localPubkey: String,
account: Account,
filterAssembler: RoomAdminCommandsFilterAssembler,
) {
val state = remember(roomATag, localPubkey) { RoomAdminCommandsQueryState(roomATag, localPubkey, account) }
KeyDataSourceSubscription(state, filterAssembler)
}
@@ -39,9 +39,11 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.audiorooms.AudioRoomForegroundService
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomAdminCommandsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomChatFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomPresenceFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomReactionsFilterAssemblerSubscription
import com.vitorpamplona.quartz.experimental.audiorooms.admin.AdminCommandEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@@ -210,6 +212,37 @@ private fun AudioRoomActivityBody(
}
}
// Per-room kind-4312 admin commands targeting THIS user. The
// signer-must-be-host-or-moderator authority check happens here
// (not in the relay) — only honour kicks where the signer's
// ParticipantTag.canSpeak() returns true on the active
// kind-30312. nostrnests' UI does the same gating.
val localPubkey = accountViewModel.account.signer.pubKey
RoomAdminCommandsFilterAssemblerSubscription(roomATag, localPubkey, accountViewModel)
LaunchedEffect(viewModel, roomATag, localPubkey) {
val filter =
Filter(
kinds = listOf(AdminCommandEvent.KIND),
tags = mapOf("a" to listOf(roomATag), "p" to listOf(localPubkey)),
)
LocalCache.observeNewEvents<AdminCommandEvent>(filter).collect { cmd ->
if (cmd.action() != AdminCommandEvent.Action.KICK) return@collect
if (cmd.targetPubkey() != localPubkey) return@collect
// Authority: the signer must currently hold a
// host/moderator role on the kind-30312 we joined.
// Falling back to "is the signer the room's pubkey"
// (the original host) covers the case where the host
// sends from the same key that wrote the room event.
val signerIsAuthorised =
cmd.pubKey == event.pubKey ||
event.participants().any { it.pubKey == cmd.pubKey && (it.isHost() || it.isModerator()) }
if (!signerIsAuthorised) return@collect
viewModel.onKick()
}
}
val wasKicked by viewModel.wasKicked.collectAsState()
LaunchedEffect(wasKicked) { if (wasKicked) onLeave() }
val ui by viewModel.uiState.collectAsState()
// Push mute + connected state up so the Activity can rebuild PIP
@@ -54,6 +54,7 @@ internal fun StagePeopleRow(
speakingNow: ImmutableSet<String>,
accountViewModel: AccountViewModel,
reactionsByPubkey: Map<String, List<com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction>> = emptyMap(),
onLongPressParticipant: ((String) -> Unit)? = null,
) {
val ringColor = MaterialTheme.colorScheme.primary
Column(modifier = Modifier.padding(top = 8.dp)) {
@@ -80,6 +81,7 @@ internal fun StagePeopleRow(
size = avatarSize,
accountViewModel = accountViewModel,
modifier = avatarModifier,
onLongClick = onLongPressParticipant?.let { cb -> { hex -> cb(hex) } },
)
val reactions = reactionsByPubkey[participant.pubKey].orEmpty()
if (reactions.isNotEmpty()) {
@@ -170,6 +170,13 @@ internal fun AudioRoomFullScreen(
}
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
var hostMenuTarget by rememberSaveable { mutableStateOf<String?>(null) }
val onLongPressParticipant: ((String) -> Unit)? =
if (isHost) {
{ target -> if (target != event.pubKey) hostMenuTarget = target }
} else {
null
}
if (onStage.isNotEmpty()) {
StagePeopleRow(
label = stringRes(R.string.audio_room_stage),
@@ -178,6 +185,7 @@ internal fun AudioRoomFullScreen(
speakingNow = ui.speakingNow,
accountViewModel = accountViewModel,
reactionsByPubkey = reactionsByPubkey,
onLongPressParticipant = onLongPressParticipant,
)
}
if (audience.isNotEmpty()) {
@@ -187,6 +195,15 @@ internal fun AudioRoomFullScreen(
avatarSize = Size35dp,
speakingNow = kotlinx.collections.immutable.persistentSetOf(),
accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant,
)
}
hostMenuTarget?.let { target ->
ParticipantHostActionsSheet(
target = target,
event = event,
accountViewModel = accountViewModel,
onDismiss = { hostMenuTarget = null },
)
}
@@ -0,0 +1,133 @@
/*
* 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 androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.audiorooms.admin.AdminCommandEvent
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
import kotlinx.coroutines.launch
/**
* Host-only management sheet for one room participant. Surfaces:
* * Promote to speaker (idempotent re-promoting a current speaker
* just re-publishes the same role; no harm, no warning needed).
* * Demote to listener (no-op for the host themselves; the underlying
* [RoomParticipantActions.demoteToListener] returns null in that
* case and the action is a silent skip).
* * Kick (sends an AdminCommandEvent.kick relay forwards;
* recipients gate on signer being host/moderator).
*
* The kick path doesn't ALSO demote the target that's nostrnests'
* own behaviour: the kicked user's session ends and any future
* presence events from them get dropped by the recipient's
* server-side filter. A future commit can chain the two actions
* if we want a "kick AND remove from participants" flow.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ParticipantHostActionsSheet(
target: String,
event: MeetingSpaceEvent,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
val roomATag =
ATag(
kind = event.kind,
pubKeyHex = event.pubKey,
dTag = event.dTag(),
relay = null,
)
fun broadcast(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<out com.vitorpamplona.quartz.nip01Core.core.Event>?) {
template ?: return
scope.launch { runCatching { accountViewModel.account.signAndComputeBroadcast(template) } }
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = target.take(8) + "",
style = MaterialTheme.typography.titleSmall,
)
Spacer(Modifier.height(8.dp))
ActionRow(stringRes(R.string.audio_room_promote_speaker)) {
broadcast(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER))
onDismiss()
}
ActionRow(stringRes(R.string.audio_room_demote_listener)) {
broadcast(RoomParticipantActions.demoteToListener(event, target))
onDismiss()
}
ActionRow(
text = stringRes(R.string.audio_room_kick_action),
color = MaterialTheme.colorScheme.error,
) {
broadcast(AdminCommandEvent.kick(roomATag, target))
onDismiss()
}
}
}
}
@Composable
private fun ActionRow(
text: String,
color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Unspecified,
onClick: () -> Unit,
) {
Text(
text = text,
color = color,
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
)
}
+3
View File
@@ -536,6 +536,9 @@
<string name="audio_room_overflow_menu">Room actions</string>
<string name="audio_room_hand_raise_queue_title">Raised hands</string>
<string name="audio_room_hand_raise_approve">Approve</string>
<string name="audio_room_promote_speaker">Promote to speaker</string>
<string name="audio_room_demote_listener">Demote to listener</string>
<string name="audio_room_kick_action">Kick</string>
<string name="audio_room_create_fab">Start space</string>
<string name="audio_room_create_title">Start a new audio room</string>
<string name="audio_room_create_field_room">Room name</string>
@@ -159,6 +159,30 @@ class AudioRoomViewModel(
private val _recentReactions = MutableStateFlow<Map<String, List<RoomReaction>>>(emptyMap())
val recentReactions: StateFlow<Map<String, List<RoomReaction>>> = _recentReactions.asStateFlow()
/**
* `true` once the local user has been kicked (#5) the platform
* layer flips this on a valid kind-4312 from a host/moderator and
* the UI can show a toast + finish the activity. Set-once; never
* unset for the lifetime of the VM (a kick survives reconnect
* attempts; the user must rejoin the room manually).
*/
private val _wasKicked = MutableStateFlow(false)
val wasKicked: StateFlow<Boolean> = _wasKicked.asStateFlow()
/**
* Mark the local user as kicked and disconnect the listener +
* speaker pumps. Idempotent. Caller (amethyst layer) is
* responsible for verifying the inbound kind-4312 was signed by
* a host or moderator on the active kind-30312 before invoking
* this the relay does not enforce that.
*/
fun onKick() {
if (closed) return
if (_wasKicked.value) return
_wasKicked.value = true
disconnect()
}
private var listener: NestsListener? = null
private var connectJob: Job? = null
private var stateObserverJob: Job? = null
@@ -298,6 +298,37 @@ class AudioRoomViewModelTest {
assertEquals(emptyMap(), vm.recentReactions.value)
}
@Test
fun onKickFlipsWasKickedAndDisconnects() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
fakeListener.emit(NestsListenerState.Connected(room = ROOM_CONFIG, negotiatedMoqVersion = 0xff000011))
assertEquals(ConnectionUiState.Connected, vm.uiState.value.connection)
assertFalse(vm.wasKicked.value)
vm.onKick()
assertTrue(vm.wasKicked.value)
// disconnect() flips connection back to Idle.
assertEquals(ConnectionUiState.Idle, vm.uiState.value.connection)
}
@Test
fun onKickIsIdempotent() =
runTest {
val fakeListener = FakeNestsListener()
val vm = newViewModel { fakeListener }
vm.connect()
fakeListener.emit(NestsListenerState.Connected(room = ROOM_CONFIG, negotiatedMoqVersion = 0xff000011))
vm.onKick()
vm.onKick()
assertTrue(vm.wasKicked.value)
}
@Test
fun publishingNowDerivesFromBroadcastStateAndMute() {
// Idle / connecting / failed: never publishing.