feat(nests): empty-stage hint, local hush, moderator/force-mute moderation

Three improvements stacked into one commit since they share files:

#3 Empty-stage hint: StageGrid no longer disappears when nobody is on
   stage. The "Stage" label stays and a quiet "Waiting for speakers…"
   line keeps the strip visible so the room doesn't look broken before
   the first speaker arrives.

#5 Local per-speaker hush: AudioPlayer gains a setVolume(Float)
   default-no-op method; AudioTrackPlayer composes mute and volume
   multiplicatively into AudioTrack.setVolume so a hushed stream stays
   silent regardless of mute state. NestViewModel exposes
   locallyHushed: ImmutableSet<String> in NestUiState plus
   setLocalHushed(pubkey, hushed) — applied at attach time so a
   re-subscribe of an already-hushed speaker stays silent. New "Hush
   this speaker" / "Restore this speaker" row in
   ParticipantHostActionsSheet, available to anyone (it affects only
   our own playback, nothing on the wire).

#4 Host moderation gaps: wires Promote-to-Moderator using the existing
   RoomParticipantActions.setRole(ROLE.MODERATOR) builder — UI gap
   only, no protocol change. Adds a new AdminCommandEvent.Action.MUTE
   variant + AdminCommandEvent.forceMute(room, target) builder; the
   sheet emits it on "Force-mute speaker" and the
   AdminCommandsCollector dispatches incoming MUTE actions to a new
   NestViewModel.onForceMuted() that routes through the existing
   setMicMuted(true) path. Honor-based, same trust model as KICK —
   relays don't enforce signer authority, the client checks the
   signer is host or moderator on the active kind-30312.
This commit is contained in:
Claude
2026-04-28 12:41:23 +00:00
parent bed1b328ce
commit d41a24f945
9 changed files with 169 additions and 12 deletions
@@ -71,6 +71,16 @@ class AdminCommandEvent(
val code: String,
) {
KICK("kick"),
/**
* Force the target speaker's mic to muted. Honor-based: the
* target's client receives the command and flips its own
* presence-side mic-mute. A non-cooperating client could
* ignore the event and keep broadcasting — same trust model
* as KICK, which a misbehaving client could also ignore by
* not disconnecting.
*/
MUTE("mute"),
;
companion object {
@@ -95,5 +105,18 @@ class AdminCommandEvent(
aTag(room)
add(PTag(target).toTagArray())
}
/**
* Build a force-mute command. Same envelope as [kick] —
* tagged for [room] + [target], action verb in content.
*/
fun forceMute(
room: ATag,
target: HexKey,
createdAt: Long = TimeUtils.now(),
) = eventTemplate<AdminCommandEvent>(KIND, Action.MUTE.code, createdAt) {
aTag(room)
add(PTag(target).toTagArray())
}
}
}