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:
+2
@@ -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.service.relayClient.searchCommand.SearchFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.articles.datasource.ArticlesFilterAssembler
|
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.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.RoomChatFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomPresenceFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomPresenceFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomReactionsFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomReactionsFilterAssembler
|
||||||
@@ -113,6 +114,7 @@ class RelaySubscriptionsCoordinator(
|
|||||||
val roomPresence = RoomPresenceFilterAssembler(client)
|
val roomPresence = RoomPresenceFilterAssembler(client)
|
||||||
val roomChat = RoomChatFilterAssembler(client)
|
val roomChat = RoomChatFilterAssembler(client)
|
||||||
val roomReactions = RoomReactionsFilterAssembler(client)
|
val roomReactions = RoomReactionsFilterAssembler(client)
|
||||||
|
val roomAdminCommands = RoomAdminCommandsFilterAssembler(client)
|
||||||
val longs = LongsFilterAssembler(client)
|
val longs = LongsFilterAssembler(client)
|
||||||
val articles = ArticlesFilterAssembler(client)
|
val articles = ArticlesFilterAssembler(client)
|
||||||
val badges = BadgesFilterAssembler(client)
|
val badges = BadgesFilterAssembler(client)
|
||||||
|
|||||||
+109
@@ -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)
|
||||||
|
}
|
||||||
+33
@@ -39,9 +39,11 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
|||||||
import com.vitorpamplona.amethyst.service.audiorooms.AudioRoomForegroundService
|
import com.vitorpamplona.amethyst.service.audiorooms.AudioRoomForegroundService
|
||||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
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.RoomChatFilterAssemblerSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomPresenceFilterAssemblerSubscription
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomPresenceFilterAssemblerSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.audiorooms.datasource.RoomReactionsFilterAssemblerSubscription
|
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.core.Address
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
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()
|
val ui by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
// Push mute + connected state up so the Activity can rebuild PIP
|
// Push mute + connected state up so the Activity can rebuild PIP
|
||||||
|
|||||||
+2
@@ -54,6 +54,7 @@ internal fun StagePeopleRow(
|
|||||||
speakingNow: ImmutableSet<String>,
|
speakingNow: ImmutableSet<String>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
reactionsByPubkey: Map<String, List<com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction>> = emptyMap(),
|
reactionsByPubkey: Map<String, List<com.vitorpamplona.amethyst.commons.viewmodels.RoomReaction>> = emptyMap(),
|
||||||
|
onLongPressParticipant: ((String) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val ringColor = MaterialTheme.colorScheme.primary
|
val ringColor = MaterialTheme.colorScheme.primary
|
||||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||||
@@ -80,6 +81,7 @@ internal fun StagePeopleRow(
|
|||||||
size = avatarSize,
|
size = avatarSize,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
modifier = avatarModifier,
|
modifier = avatarModifier,
|
||||||
|
onLongClick = onLongPressParticipant?.let { cb -> { hex -> cb(hex) } },
|
||||||
)
|
)
|
||||||
val reactions = reactionsByPubkey[participant.pubKey].orEmpty()
|
val reactions = reactionsByPubkey[participant.pubKey].orEmpty()
|
||||||
if (reactions.isNotEmpty()) {
|
if (reactions.isNotEmpty()) {
|
||||||
|
|||||||
+17
@@ -170,6 +170,13 @@ internal fun AudioRoomFullScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val reactionsByPubkey by viewModel.recentReactions.collectAsState()
|
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()) {
|
if (onStage.isNotEmpty()) {
|
||||||
StagePeopleRow(
|
StagePeopleRow(
|
||||||
label = stringRes(R.string.audio_room_stage),
|
label = stringRes(R.string.audio_room_stage),
|
||||||
@@ -178,6 +185,7 @@ internal fun AudioRoomFullScreen(
|
|||||||
speakingNow = ui.speakingNow,
|
speakingNow = ui.speakingNow,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
reactionsByPubkey = reactionsByPubkey,
|
reactionsByPubkey = reactionsByPubkey,
|
||||||
|
onLongPressParticipant = onLongPressParticipant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (audience.isNotEmpty()) {
|
if (audience.isNotEmpty()) {
|
||||||
@@ -187,6 +195,15 @@ internal fun AudioRoomFullScreen(
|
|||||||
avatarSize = Size35dp,
|
avatarSize = Size35dp,
|
||||||
speakingNow = kotlinx.collections.immutable.persistentSetOf(),
|
speakingNow = kotlinx.collections.immutable.persistentSetOf(),
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
|
onLongPressParticipant = onLongPressParticipant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hostMenuTarget?.let { target ->
|
||||||
|
ParticipantHostActionsSheet(
|
||||||
|
target = target,
|
||||||
|
event = event,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onDismiss = { hostMenuTarget = null },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+133
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -536,6 +536,9 @@
|
|||||||
<string name="audio_room_overflow_menu">Room actions</string>
|
<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_queue_title">Raised hands</string>
|
||||||
<string name="audio_room_hand_raise_approve">Approve</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_fab">Start space</string>
|
||||||
<string name="audio_room_create_title">Start a new audio room</string>
|
<string name="audio_room_create_title">Start a new audio room</string>
|
||||||
<string name="audio_room_create_field_room">Room name</string>
|
<string name="audio_room_create_field_room">Room name</string>
|
||||||
|
|||||||
+24
@@ -159,6 +159,30 @@ class AudioRoomViewModel(
|
|||||||
private val _recentReactions = MutableStateFlow<Map<String, List<RoomReaction>>>(emptyMap())
|
private val _recentReactions = MutableStateFlow<Map<String, List<RoomReaction>>>(emptyMap())
|
||||||
val recentReactions: StateFlow<Map<String, List<RoomReaction>>> = _recentReactions.asStateFlow()
|
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 listener: NestsListener? = null
|
||||||
private var connectJob: Job? = null
|
private var connectJob: Job? = null
|
||||||
private var stateObserverJob: Job? = null
|
private var stateObserverJob: Job? = null
|
||||||
|
|||||||
+31
@@ -298,6 +298,37 @@ class AudioRoomViewModelTest {
|
|||||||
assertEquals(emptyMap(), vm.recentReactions.value)
|
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
|
@Test
|
||||||
fun publishingNowDerivesFromBroadcastStateAndMute() {
|
fun publishingNowDerivesFromBroadcastStateAndMute() {
|
||||||
// Idle / connecting / failed: never publishing.
|
// Idle / connecting / failed: never publishing.
|
||||||
|
|||||||
Reference in New Issue
Block a user