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
@@ -173,13 +173,16 @@ private fun AdminCommandsCollector(
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
val signerIsAuthorised =
cmd.pubKey == event.pubKey ||
event.participants().any { it.pubKey == cmd.pubKey && (it.isHost() || it.isModerator()) }
if (!signerIsAuthorised) return@collect
viewModel.onKick()
when (cmd.action()) {
AdminCommandEvent.Action.KICK -> viewModel.onKick()
AdminCommandEvent.Action.MUTE -> viewModel.onForceMuted()
null -> Unit // unknown verb, ignore
}
}
}
}
@@ -33,6 +33,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -82,6 +83,7 @@ internal fun ParticipantHostActionsSheet(
event: MeetingSpaceEvent,
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
nestViewModel: com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel? = null,
isLocalUserHost: Boolean = accountViewModel.account.signer.pubKey == event.pubKey,
catalog: com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog? = null,
) {
@@ -252,6 +254,28 @@ internal fun ParticipantHostActionsSheet(
onDismiss()
}
// Local hush — silences only this speaker in our own
// playback. Available to anyone (not host-only) since it
// affects nothing on the wire. Skip self because we don't
// subscribe to our own broadcast loopback.
if (nestViewModel != null && target != accountViewModel.account.signer.pubKey) {
val nestUi by nestViewModel.uiState.collectAsState()
val isHushedLocally = target in nestUi.locallyHushed
ActionRow(
text =
stringRes(
if (isHushedLocally) {
R.string.nest_hush_local_restore
} else {
R.string.nest_hush_local
},
),
) {
nestViewModel.setLocalHushed(target, !isHushedLocally)
onDismiss()
}
}
// Host-only rows.
if (isLocalUserHost && target != event.pubKey) {
Spacer(Modifier.height(4.dp))
@@ -259,10 +283,21 @@ internal fun ParticipantHostActionsSheet(
broadcast(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER))
onDismiss()
}
ActionRow(stringRes(R.string.nest_promote_moderator)) {
broadcast(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR))
onDismiss()
}
ActionRow(stringRes(R.string.nest_demote_listener)) {
broadcast(RoomParticipantActions.demoteToListener(event, target))
onDismiss()
}
ActionRow(
text = stringRes(R.string.nest_force_mute),
color = MaterialTheme.colorScheme.error,
) {
broadcast(AdminCommandEvent.forceMute(roomATag, target))
onDismiss()
}
ActionRow(
text = stringRes(R.string.nest_kick_action),
color = MaterialTheme.colorScheme.error,
@@ -290,6 +290,7 @@ internal fun NestFullScreen(
target = target,
event = event,
accountViewModel = accountViewModel,
nestViewModel = viewModel,
onDismiss = { hostMenuTarget = null },
catalog = speakerCatalogs[target],
)
@@ -120,7 +120,6 @@ internal fun StageGrid(
connectingSpeakers: ImmutableSet<String> = persistentSetOf(),
onLongPressParticipant: ((String) -> Unit)? = null,
) {
if (members.isEmpty()) return
// Float currently-speaking members to the top so the listener can
// see who they're hearing without scrolling. sortedBy is stable in
// Kotlin — speakers retain relative order among themselves, as do
@@ -137,6 +136,23 @@ internal fun StageGrid(
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 6.dp),
)
if (members.isEmpty()) {
// Keep the strip visible so the room doesn't look broken
// when nobody is on stage yet — the title plus a quiet
// hint signals "waiting state" without taking the chat /
// audience tab's space below.
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringRes(R.string.nest_stage_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return@Column
}
LazyVerticalGrid(
columns = GridCells.Adaptive(STAGE_CELL_MIN),
modifier = Modifier.fillMaxWidth().heightIn(max = STAGE_MAX_HEIGHT),
+5
View File
@@ -492,6 +492,7 @@
<string name="meeting_room_in_space">In %1$s</string>
<string name="meeting_room_in_space_unknown">a Nest</string>
<string name="nest_stage">Stage</string>
<string name="nest_stage_empty">Waiting for speakers…</string>
<string name="nest_audience">Audience</string>
<string name="nest_raise_hand">Raise hand</string>
<string name="nest_lower_hand">Lower hand</string>
@@ -569,8 +570,12 @@
<string name="nest_hand_raise_queue_title">Raised hands</string>
<string name="nest_hand_raise_approve">Approve</string>
<string name="nest_promote_speaker">Promote to speaker</string>
<string name="nest_promote_moderator">Promote to moderator</string>
<string name="nest_demote_listener">Demote to listener</string>
<string name="nest_force_mute">Force-mute speaker</string>
<string name="nest_kick_action">Kick</string>
<string name="nest_hush_local">Hush this speaker</string>
<string name="nest_hush_local_restore">Restore this speaker</string>
<string name="nest_participant_view_profile">View profile</string>
<string name="nest_participant_zap">Send zap</string>
<string name="nest_participant_zap_split_unsupported">Split zaps are not supported from inside a nest. Open the profile screen to send.</string>
@@ -231,6 +231,21 @@ class NestViewModel(
disconnect()
}
/**
* Honor an inbound kind-4312 force-mute targeted at the local
* user. No-ops when we aren't broadcasting; otherwise routes
* through the existing [setMicMuted] path so the wire mute and
* UI state both flip in sync. Caller (the AdminCommandsCollector)
* is responsible for verifying the signer is host/moderator on
* the active kind-30312 — the relay does not enforce that.
*/
fun onForceMuted() {
if (closed) return
if (broadcastHandle == null) return
if ((_uiState.value.broadcast as? BroadcastUiState.Broadcasting)?.isMuted == true) return
setMicMuted(true)
}
private var listener: NestsListener? = null
private var connectJob: Job? = null
private var stateObserverJob: Job? = null
@@ -307,6 +322,31 @@ class NestViewModel(
activeSubscriptions.values.forEach { it.player?.setMutedSafe(muted) }
}
/**
* Locally silence (hush) one speaker without affecting the rest of
* the room. Independent of [setMuted] (room-wide). The hush is
* applied via [AudioPlayer.setVolume] so the network + decode
* pipeline keeps running — re-enabling is sample-accurate.
*
* The set survives subscription churn: re-subscribing to a hushed
* speaker re-applies the gain on the new player at attach time.
*/
fun setLocalHushed(
pubkey: String,
hushed: Boolean,
) {
if (closed) return
val updated =
_uiState.value.locallyHushed.let {
if (hushed) it + pubkey else it - pubkey
}
if (updated == _uiState.value.locallyHushed) return
_uiState.update { it.copy(locallyHushed = updated.toPersistentSet()) }
activeSubscriptions[pubkey]?.player?.runCatching {
setVolume(if (hushed) 0f else 1f)
}
}
/**
* Toggle whether we hold a speaker slot. Tier-2's "leave the stage"
* tap flips this to `false`; the next kind-10312 heartbeat picks up
@@ -787,10 +827,14 @@ class NestViewModel(
}
try {
val isMuted = _uiState.value.isMuted
val isHushed = pubkey in _uiState.value.locallyHushed
val roomPlayer = NestPlayer(decoder, player, viewModelScope)
// Apply current mute state before play() opens the device so the
// first frame respects it.
// Apply current mute + per-speaker hush state before play()
// opens the device so the first frame respects them.
player.setMutedSafe(isMuted)
if (isHushed) {
runCatching { player.setVolume(0f) }
}
// Tap the object flow to drive the speaking-now indicator before
// the decoder consumes it.
val instrumented = handle.objects.onEach { onSpeakerActivity(pubkey) }
@@ -1114,6 +1158,14 @@ data class NestUiState(
* `["onstage", "0|1"]` tag on emitted kind-10312 presence events.
*/
val onStageNow: Boolean = true,
/**
* Pubkeys the local user has chosen to silence in their own
* playback ("hush"). Independent of [isMuted]. Persists across
* subscription churn so re-subscribing to a hushed speaker stays
* silent. Drives a "Hush this speaker locally" toggle in the
* participant context sheet.
*/
val locallyHushed: ImmutableSet<String> = persistentSetOf(),
) {
/**
* Derived: are we currently pushing audio packets to the relay?
@@ -42,6 +42,7 @@ class AudioTrackPlayer(
) : AudioPlayer {
private var track: AudioTrack? = null
private var muted: Boolean = false
private var volume: Float = 1f
override fun start() {
if (track != null) return
@@ -105,7 +106,7 @@ class AudioTrackPlayer(
t,
)
}
applyMuteVolume(newTrack, muted)
applyMuteVolume(newTrack)
track = newTrack
}
@@ -126,7 +127,12 @@ class AudioTrackPlayer(
override fun setMuted(muted: Boolean) {
this.muted = muted
track?.let { applyMuteVolume(it, muted) }
track?.let { applyMuteVolume(it) }
}
override fun setVolume(volume: Float) {
this.volume = volume.coerceIn(0f, 1f)
track?.let { applyMuteVolume(it) }
}
override fun stop() {
@@ -141,13 +147,14 @@ class AudioTrackPlayer(
@Suppress("unused")
val voiceCallUsage: Int get() = AudioManager.STREAM_VOICE_CALL // kept for documentation
private fun applyMuteVolume(
track: AudioTrack,
muted: Boolean,
) {
private fun applyMuteVolume(track: AudioTrack) {
// setVolume is preferred over pause(): it keeps the streaming pipeline
// running so unmute is sample-accurate and there's no AudioTrack-restart
// glitch. AudioTrack default gain is 1.0 (RFC: AudioTrack#setVolume).
runCatching { track.setVolume(if (muted) 0f else 1f) }
// Mute and per-stream volume compose multiplicatively — a muted stream
// is silent regardless of volume, and a stream at volume 0 is silent
// regardless of mute.
val gain = if (muted) 0f else volume
runCatching { track.setVolume(gain) }
}
}
@@ -121,6 +121,21 @@ interface AudioPlayer {
*/
fun setMuted(muted: Boolean)
/**
* Per-stream output gain, range `[0.0, 1.0]`. 1.0 is the device
* default; 0.0 silences this stream while leaving any other
* concurrent players untouched. Used to "hush" one speaker
* locally without affecting the rest of the room.
*
* Independent of [setMuted]: muted streams are silent regardless of
* gain, and an unmuted stream at gain 0 is still silent. The two
* controls compose multiplicatively. Default gain is 1.0.
*
* Default implementation is a no-op so existing fakes / capture-only
* actuals don't have to grow a method they'll never use.
*/
fun setVolume(volume: Float) {}
/** Stop playback and release resources. After this, the player is unusable. */
fun stop()
}
@@ -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())
}
}
}