From d1be2ae19e9bc13f7db41b7a0720ef5a03e5da3b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 22:49:47 +0000 Subject: [PATCH] feat(audio-rooms): kick + per-participant host actions (T1 #6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end glue for the kick action and the broader per-participant management surface: AudioRoomViewModel.wasKicked: StateFlow 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 --- .../RelaySubscriptionsCoordinator.kt | 2 + .../RoomAdminCommandsFilterAssembler.kt | 109 ++++++++++++++ .../room/AudioRoomActivityContent.kt | 33 +++++ .../audiorooms/room/AudioRoomCommon.kt | 2 + .../audiorooms/room/AudioRoomFullScreen.kt | 17 +++ .../room/ParticipantHostActionsSheet.kt | 133 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 3 + .../commons/viewmodels/AudioRoomViewModel.kt | 24 ++++ .../viewmodels/AudioRoomViewModelTest.kt | 31 ++++ 9 files changed, 354 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/RoomAdminCommandsFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 12f1b4d1d..9482638b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/RoomAdminCommandsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/RoomAdminCommandsFilterAssembler.kt new file mode 100644 index 000000000..2d5e4f4b4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/datasource/RoomAdminCommandsFilterAssembler.kt @@ -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, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RoomAdminCommandsQueryState, + since: SincePerRelayMap?, + ): List { + 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() { + 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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt index cc1c00c9f..b77d55ae0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt @@ -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(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomCommon.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomCommon.kt index d8756369a..d635f840a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomCommon.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomCommon.kt @@ -54,6 +54,7 @@ internal fun StagePeopleRow( speakingNow: ImmutableSet, accountViewModel: AccountViewModel, reactionsByPubkey: Map> = 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()) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt index 9199a95ce..fe4ad332a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt @@ -170,6 +170,13 @@ internal fun AudioRoomFullScreen( } val reactionsByPubkey by viewModel.recentReactions.collectAsState() + var hostMenuTarget by rememberSaveable { mutableStateOf(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 }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt new file mode 100644 index 000000000..bab005841 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/ParticipantHostActionsSheet.kt @@ -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?) { + 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), + ) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 9e0151187..c61be4944 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -536,6 +536,9 @@ Room actions Raised hands Approve + Promote to speaker + Demote to listener + Kick Start space Start a new audio room Room name diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt index 38555fbcb..d062c14fd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt @@ -159,6 +159,30 @@ class AudioRoomViewModel( private val _recentReactions = MutableStateFlow>>(emptyMap()) val recentReactions: StateFlow>> = _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 = _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 diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt index a604d7dcb..662a0a793 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModelTest.kt @@ -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.