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.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)
|
||||
|
||||
+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.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
|
||||
|
||||
+2
@@ -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()) {
|
||||
|
||||
+17
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+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_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>
|
||||
|
||||
+24
@@ -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
|
||||
|
||||
+31
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user