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>