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
@@ -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.