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:
+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