Merge pull request #2858 from vitorpamplona/claude/review-participant-dropdown-V0eJB

Refactor participant sheet: extract sub-composables, add confirmations
This commit is contained in:
Vitor Pamplona
2026-05-12 09:34:56 -04:00
committed by GitHub
7 changed files with 528 additions and 225 deletions
@@ -20,6 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -27,52 +30,61 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ZapCustomDialog
import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
/**
* Per-participant context sheet (T2 #2). Always shows the
* audience-friendly rows (View profile / Follow-Unfollow / Mute);
* appends the host management rows (Promote / Demote / Kick) when
* the local user is the room's host.
* Per-participant context sheet (T2 #2). Long-press on any participant
* avatar — or single-tap on an audience / hand-raise avatar — opens
* this sheet.
*
* * Promote to speaker — idempotent re-publish.
* * Demote to listener — no-op for the host themselves
* (RoomParticipantActions.demoteToListener returns null in
* that case).
* * Kick — sends an AdminCommandEvent.kick. Relay just forwards;
* recipients gate on signer being host/moderator.
* Top-level layout: a [ParticipantSheetHeader] followed by
* [AudienceActions] (View profile / Zap / Follow / Mute / Local hush)
* and, when the local user is the host, [HostActions]
* (Promote / Demote / Force-mute / Kick).
*
* The kick path doesn't ALSO demote the target — that's
* nostrnests' behaviour: the kicked user's session ends and any
* future presence events from them get dropped by the recipient's
* client-side filter.
* Destructive host actions (Kick, Force-mute) and the Zap flow are
* confirmed via a top-level [SheetDialog] state so the sub-composables
* never own dialog window state themselves.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -80,248 +92,386 @@ internal fun ParticipantHostActionsSheet(
target: String,
event: MeetingSpaceEvent,
accountViewModel: AccountViewModel,
nestViewModel: NestViewModel,
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,
catalog: RoomSpeakerCatalog? = null,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val targetUser = remember(target) { LocalCache.getOrCreateUser(target) }
val roomATag =
ATag(
kind = event.kind,
pubKeyHex = event.pubKey,
dTag = event.dTag(),
relay = null,
)
// Reactive display name — UsernameDisplay observes this for the
// header, and we reuse the same flow for toast text so the header
// and toast never disagree (e.g. metadata arriving mid-sheet).
val userInfo by observeUserInfo(targetUser, accountViewModel)
val displayName = userInfo?.info?.bestName() ?: targetUser.pubkeyDisplayHex()
val toasts = rememberParticipantToasts(displayName)
// Go through accountViewModel.launchSigner so the broadcast runs on
// viewModelScope (survives onDismiss() removing the sheet from
// composition) and signer errors surface as toasts instead of being
// silently swallowed.
fun broadcast(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<out com.vitorpamplona.quartz.nip01Core.core.Event>?) {
template ?: return
accountViewModel.launchSigner { accountViewModel.account.signAndComputeBroadcast(template) }
var openDialog by rememberSaveable(stateSaver = SheetDialogSaver) {
mutableStateOf<SheetDialog?>(null)
}
val targetUser =
remember(target) {
com.vitorpamplona.amethyst.model.LocalCache
.getOrCreateUser(target)
}
val isFollowing = accountViewModel.isFollowing(target)
val isHidden =
remember(target) {
accountViewModel.account.hiddenUsers.flow.value.hiddenUsers
.contains(target)
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = target.take(8) + "…",
style = MaterialTheme.typography.titleSmall,
)
// moq-lite catalog summary, when the speaker has published
// one. Tells the audience what codec / sample rate / channel
// count this broadcast carries — visible cue that the seat
// is actually a live audio source vs a silent stage slot.
catalog?.primaryAudio()?.describe()?.let { line ->
Text(
text = line,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
ParticipantSheetHeader(targetUser, catalog, accountViewModel)
Spacer(Modifier.height(8.dp))
// View Profile — NestActivity is a separate Android
// Activity without its own nav stack, so the deep-link
// path goes through MainActivity. Launching `nostr:npub1...`
// via ACTION_VIEW lets the existing MainActivity URI
// handler route to Route.Profile (matches the path that
// an external app or a clicked link inside the feed
// would take). NEW_TASK + REORDER_TO_FRONT brings the
// already-running MainActivity instance forward; the
// audio-room foreground service keeps audio alive while
// the user is on the profile screen.
val context = LocalContext.current
val noAppMessage = stringRes(R.string.nest_no_app_to_open_link)
ActionRow(stringRes(R.string.nest_participant_view_profile)) {
val npub = NPub.create(target)
val launched =
runCatching {
context.startActivity(
android.content
.Intent(android.content.Intent.ACTION_VIEW)
.apply {
data = android.net.Uri.parse("nostr:$npub")
setClass(context, MainActivity::class.java)
addFlags(
android.content.Intent.FLAG_ACTIVITY_NEW_TASK or
android.content.Intent.FLAG_ACTIVITY_REORDER_TO_FRONT,
)
},
)
}.isSuccess
if (!launched) {
accountViewModel.toastManager.toast(
R.string.nest_chat_send_failed_title,
noAppMessage,
user = null,
)
}
onDismiss()
}
// Zap — opens the standard ZapCustomDialog targeting the
// user's kind-0 metadata note (the canonical "zap a user"
// base note throughout amethyst). Single-payable invoices
// hand off to a wallet via payViaIntent; split-zap targets
// (uncommon for personal LN addresses) are routed back to
// the profile screen rather than wired into a non-nav-host
// ModalBottomSheet.
var showZapDialog by rememberSaveable { mutableStateOf(false) }
ActionRow(stringRes(R.string.nest_participant_zap)) {
showZapDialog = true
}
if (showZapDialog) {
val targetMetadataNote =
remember(target) {
com.vitorpamplona.amethyst.model.LocalCache
.getOrCreateAddressableNote(MetadataEvent.createAddress(target))
}
// Pre-resolved at composition because callbacks below run
// outside a @Composable scope (stringRes is composable-only).
val splitUnsupportedMsg = stringRes(R.string.nest_participant_zap_split_unsupported)
ZapCustomDialog(
onZapStarts = {},
onClose = {
showZapDialog = false
onDismiss()
},
onError = { _, message, user ->
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
UserBasedErrorMessage(message, user),
)
},
onProgress = { /* no progress UI inside the sheet — single-payable handoff is fast */ },
onPayViaIntent = { payables ->
if (payables.size == 1) {
val payable = payables.first()
payViaIntent(payable.invoice, context, { }) { error ->
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
UserBasedErrorMessage(error, payable.info.user),
)
}
} else {
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
UserBasedErrorMessage(splitUnsupportedMsg, null),
)
}
},
AudienceActions(
target = target,
targetUser = targetUser,
accountViewModel = accountViewModel,
nestViewModel = nestViewModel,
toasts = toasts,
onZapRequested = { openDialog = SheetDialog.Zap },
onDismiss = onDismiss,
)
if (isLocalUserHost && target != event.pubKey) {
HostActions(
target = target,
event = event,
accountViewModel = accountViewModel,
baseNote = targetMetadataNote,
nestViewModel = nestViewModel,
toasts = toasts,
onForceMuteRequested = { openDialog = SheetDialog.ForceMute },
onKickRequested = { openDialog = SheetDialog.Kick },
onDismiss = onDismiss,
)
}
ActionRow(
text =
stringRes(
if (isFollowing) {
R.string.nest_participant_unfollow
} else {
R.string.nest_participant_follow
},
),
) {
if (isFollowing) accountViewModel.unfollow(targetUser) else accountViewModel.follow(targetUser)
onDismiss()
}
ActionRow(
text =
stringRes(
if (isHidden) {
R.string.nest_participant_unmute
} else {
R.string.nest_participant_mute
},
),
) {
if (isHidden) accountViewModel.show(targetUser) else accountViewModel.hide(targetUser)
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)
// Top-level dialog dispatch so the sub-composables never own
// window state and the destructive actions live next to the
// confirm dialog they belong to.
when (openDialog) {
SheetDialog.Zap -> {
ParticipantZapDialog(
target = target,
accountViewModel = accountViewModel,
zapSplitUnsupported = toasts.zapSplitUnsupported,
onClose = {
openDialog = null
onDismiss()
}
}
},
)
}
// Host-only rows.
if (isLocalUserHost && target != event.pubKey) {
Spacer(Modifier.height(4.dp))
ActionRow(stringRes(R.string.nest_promote_speaker)) {
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,
) {
SheetDialog.Kick -> {
DestructiveConfirmDialog(
title = stringRes(R.string.nest_confirm_kick_title),
body = stringRes(R.string.nest_confirm_kick_body, displayName),
confirmLabel = stringRes(R.string.nest_confirm_kick_confirm),
onConfirm = {
openDialog = null
val roomATag = ATag(event.kind, event.pubKey, event.dTag(), null)
// Two-step kick mirroring nostrnests' ProfileCard.tsx:
// 1. ephemeral kind-4312 ["action","kick"] kicks the
// user off the audio plane
// 2. re-published kind-30312 with the target's p-tag
// dropped removes them from the participant grid
// so future presence events don't re-render them
broadcast(AdminCommandEvent.kick(roomATag, target))
broadcast(RoomParticipantActions.removeParticipant(event, target))
broadcast(accountViewModel, AdminCommandEvent.kick(roomATag, target))
broadcast(accountViewModel, RoomParticipantActions.removeParticipant(event, target))
accountViewModel.toastManager.toast(toasts.title, toasts.kickSent)
onDismiss()
}
}
},
onDismiss = { openDialog = null },
)
}
SheetDialog.ForceMute -> {
DestructiveConfirmDialog(
title = stringRes(R.string.nest_confirm_force_mute_title),
body = stringRes(R.string.nest_confirm_force_mute_body, displayName),
confirmLabel = stringRes(R.string.nest_confirm_force_mute_confirm),
onConfirm = {
openDialog = null
val roomATag = ATag(event.kind, event.pubKey, event.dTag(), null)
broadcast(accountViewModel, AdminCommandEvent.forceMute(roomATag, target))
accountViewModel.toastManager.toast(toasts.title, toasts.forceMuteSent)
onDismiss()
},
onDismiss = { openDialog = null },
)
}
null -> {
Unit
}
}
}
@Composable
private fun ParticipantSheetHeader(
targetUser: User,
catalog: RoomSpeakerCatalog?,
accountViewModel: AccountViewModel,
) {
UsernameDisplay(baseUser = targetUser, accountViewModel = accountViewModel)
// moq-lite catalog summary, when the speaker has published one.
// Tells the audience what codec / sample rate / channel count this
// broadcast carries — visible cue that the seat is actually a live
// audio source vs a silent stage slot.
catalog?.primaryAudio()?.describe()?.let { line ->
Text(
text = line,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun AudienceActions(
target: String,
targetUser: User,
accountViewModel: AccountViewModel,
nestViewModel: NestViewModel,
toasts: ParticipantToasts,
onZapRequested: () -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
// Live state — read via collectAsState so the Follow / Mute labels
// update if the kind-3 / mute-list publish lands while the sheet is
// still on screen. Snapshotting `.flow.value` left stale labels:
// re-opening the sheet right after a follow still showed "Follow".
val followState by accountViewModel.account.kind3FollowList.flow
.collectAsState()
val hiddenState by accountViewModel.account.hiddenUsers.flow
.collectAsState()
val nestUi by nestViewModel.uiState.collectAsState()
val nestPresences by nestViewModel.presences.collectAsState()
val isFollowing = target in followState.authors
val isHidden = target in hiddenState.hiddenUsers
val isSelf = target == accountViewModel.account.signer.pubKey
val targetIsBroadcasting = nestPresences[target]?.publishing == true
// View Profile — NestActivity is a separate Android Activity
// without its own nav stack, so the deep-link path goes through
// MainActivity. Launching `nostr:npub1...` via ACTION_VIEW lets the
// existing MainActivity URI handler route to Route.Profile (matches
// the path an external app or clicked link inside the feed takes).
// NEW_TASK + REORDER_TO_FRONT brings the already-running MainActivity
// instance forward; the audio-room foreground service keeps audio
// alive while the user is on the profile screen.
ActionRow(stringRes(R.string.nest_participant_view_profile)) {
openProfileInMainActivity(context, target, accountViewModel, toasts.noAppMessage)
onDismiss()
}
ActionRow(stringRes(R.string.nest_participant_zap)) { onZapRequested() }
ActionRow(
text =
stringRes(
if (isFollowing) R.string.nest_participant_unfollow else R.string.nest_participant_follow,
),
) {
if (isFollowing) {
accountViewModel.unfollow(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.unfollowSent)
} else {
accountViewModel.follow(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.followSent)
}
onDismiss()
}
ActionRow(
text =
stringRes(
if (isHidden) R.string.nest_participant_unmute else R.string.nest_participant_mute,
),
) {
if (isHidden) {
accountViewModel.show(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.unmuteSent)
} else {
accountViewModel.hide(targetUser)
accountViewModel.toastManager.toast(toasts.title, toasts.muteSent)
}
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. Hidden when the target isn't currently
// broadcasting: the volume gate attaches to an active subscription,
// so a hush against a pure listener was a silent no-op.
if (!isSelf && targetIsBroadcasting) {
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()
}
}
}
@Composable
private fun HostActions(
target: String,
event: MeetingSpaceEvent,
accountViewModel: AccountViewModel,
nestViewModel: NestViewModel,
toasts: ParticipantToasts,
onForceMuteRequested: () -> Unit,
onKickRequested: () -> Unit,
onDismiss: () -> Unit,
) {
// Pull the participant tag once; we use its role for Promote /
// Demote visibility and canSpeak() — defined on ParticipantTag —
// for the Force-mute gate.
val targetTag = remember(event, target) { event.participants().firstOrNull { it.pubKey == target } }
val targetRole = targetTag?.effectiveRole()
val targetCanSpeak = targetTag?.canSpeak() == true
val nestPresences by nestViewModel.presences.collectAsState()
val targetIsBroadcasting = nestPresences[target]?.publishing == true
fun hostAction(
template: EventTemplate<out Event>?,
toastMsg: String,
) {
broadcast(accountViewModel, template)
accountViewModel.toastManager.toast(toasts.title, toastMsg)
onDismiss()
}
Spacer(Modifier.height(4.dp))
// Hide Promote / Demote rows when the target already has that
// role — re-publishing kind-30312 with the same role string is
// wasted relay traffic and an obvious UX tell.
if (targetRole != ROLE.SPEAKER) {
ActionRow(stringRes(R.string.nest_promote_speaker)) {
hostAction(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER), toasts.promoteSpeakerSent)
}
}
if (targetRole != ROLE.MODERATOR) {
ActionRow(stringRes(R.string.nest_promote_moderator)) {
hostAction(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR), toasts.promoteModeratorSent)
}
}
// Demote only when the target actually has a role above listener —
// otherwise the call is a no-op (RoomParticipantActions.demoteToListener)
// and the visible row sets up a misleading expectation.
if (targetRole != null && targetRole != ROLE.PARTICIPANT) {
ActionRow(stringRes(R.string.nest_demote_listener)) {
hostAction(RoomParticipantActions.demoteToListener(event, target), toasts.demoteListenerSent)
}
}
// Force-mute is honor-based: the target's client must honor the
// verb. Hidden unless the target can speak (by role) AND is
// broadcasting right now — the command has nothing to mute
// otherwise. The subtitle surfaces the "may be ignored" caveat so
// the host doesn't expect a hard mute against non-Amethyst clients.
if (targetCanSpeak && targetIsBroadcasting) {
ActionRow(
text = stringRes(R.string.nest_force_mute),
color = MaterialTheme.colorScheme.error,
) { onForceMuteRequested() }
Text(
text = stringRes(R.string.nest_force_mute_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier
.fillMaxWidth()
.padding(start = 4.dp, bottom = 4.dp),
)
}
ActionRow(
text = stringRes(R.string.nest_kick_action),
color = MaterialTheme.colorScheme.error,
) { onKickRequested() }
}
@Composable
private fun ParticipantZapDialog(
target: String,
accountViewModel: AccountViewModel,
zapSplitUnsupported: String,
onClose: () -> Unit,
) {
val context = LocalContext.current
val targetMetadataNote =
remember(target) {
LocalCache.getOrCreateAddressableNote(MetadataEvent.createAddress(target))
}
ZapCustomDialog(
onZapStarts = {},
onClose = onClose,
onError = { _, message, user ->
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
UserBasedErrorMessage(message, user),
)
},
onProgress = { /* no progress UI inside the sheet — single-payable handoff is fast */ },
onPayViaIntent = { payables ->
if (payables.size == 1) {
val payable = payables.first()
payViaIntent(payable.invoice, context, { }) { error ->
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
UserBasedErrorMessage(error, payable.info.user),
)
}
} else {
// Split-zap targets are uncommon for personal LN addresses
// and the ModalBottomSheet doesn't host a nav graph, so
// we route the user back to the profile screen instead
// of half-implementing the split flow here.
accountViewModel.toastManager.toast(
R.string.error_dialog_zap_error,
UserBasedErrorMessage(zapSplitUnsupported, null),
)
}
},
accountViewModel = accountViewModel,
baseNote = targetMetadataNote,
)
}
@Composable
private fun DestructiveConfirmDialog(
title: String,
body: String,
confirmLabel: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(body) },
confirmButton = {
TextButton(
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
onClick = onConfirm,
) { Text(confirmLabel) }
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.nest_confirm_cancel))
}
},
)
}
@Composable
private fun ActionRow(
text: String,
color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Unspecified,
color: Color = Color.Unspecified,
onClick: () -> Unit,
) {
Text(
@@ -335,3 +485,93 @@ private fun ActionRow(
.padding(vertical = 12.dp),
)
}
/** Fire-and-forget broadcast; null templates short-circuit. */
private fun broadcast(
accountViewModel: AccountViewModel,
template: EventTemplate<out Event>?,
) {
template ?: return
// viewModelScope keeps the coroutine alive past onDismiss() removing
// the sheet from composition; signer errors surface as toasts via
// launchSigner's catch arms.
accountViewModel.launchSigner { accountViewModel.account.signAndComputeBroadcast(template) }
}
private fun openProfileInMainActivity(
context: Context,
target: String,
accountViewModel: AccountViewModel,
noAppMessage: String,
) {
val npub = NPub.create(target)
val launched =
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse("nostr:$npub")
setClass(context, MainActivity::class.java)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
},
)
}.isSuccess
if (!launched) {
accountViewModel.toastManager.toast(
R.string.nest_chat_send_failed_title,
noAppMessage,
user = null,
)
}
}
/**
* Mutually-exclusive dialog selector for the sheet. Serialised as the
* enum constant name so `rememberSaveable` survives configuration
* changes without a custom Parcelable wrapper.
*/
private enum class SheetDialog { Zap, Kick, ForceMute }
private val SheetDialogSaver: Saver<SheetDialog?, String> =
Saver(
save = { it?.name ?: "" },
restore = { name -> name.takeIf { it.isNotEmpty() }?.let(SheetDialog::valueOf) },
)
/**
* Pre-resolved strings for the sheet's success toasts and inline error
* fallbacks. Composable `stringRes(...)` can only be called from
* @Composable scope, so the formatted-with-displayName variants are
* computed once at composition and held in this holder; row onClicks
* (which are not @Composable) read them by reference.
*/
private data class ParticipantToasts(
val title: String,
val followSent: String,
val unfollowSent: String,
val muteSent: String,
val unmuteSent: String,
val promoteSpeakerSent: String,
val promoteModeratorSent: String,
val demoteListenerSent: String,
val forceMuteSent: String,
val kickSent: String,
val noAppMessage: String,
val zapSplitUnsupported: String,
)
@Composable
private fun rememberParticipantToasts(displayName: String): ParticipantToasts =
ParticipantToasts(
title = stringRes(R.string.nest_toast_action_title),
followSent = stringRes(R.string.nest_toast_follow_sent, displayName),
unfollowSent = stringRes(R.string.nest_toast_unfollow_sent, displayName),
muteSent = stringRes(R.string.nest_toast_mute_sent, displayName),
unmuteSent = stringRes(R.string.nest_toast_unmute_sent, displayName),
promoteSpeakerSent = stringRes(R.string.nest_toast_promote_speaker_sent, displayName),
promoteModeratorSent = stringRes(R.string.nest_toast_promote_moderator_sent, displayName),
demoteListenerSent = stringRes(R.string.nest_toast_demote_listener_sent, displayName),
forceMuteSent = stringRes(R.string.nest_toast_force_mute_sent, displayName),
kickSent = stringRes(R.string.nest_toast_kick_sent, displayName),
noAppMessage = stringRes(R.string.nest_no_app_to_open_link),
zapSplitUnsupported = stringRes(R.string.nest_participant_zap_split_unsupported),
)
@@ -83,13 +83,22 @@ internal object RoomParticipantActions {
/**
* Demote a speaker / moderator back to listener. Same guarantees
* as [setRole] — host can't be demoted; absent target is a no-op
* (the row just disappears from the participant list).
* as [setRole] — host can't be demoted.
*
* If the target isn't currently in the participant list at all,
* returns null — they're already a pure-audience listener and
* re-publishing the kind-30312 to add them back as PARTICIPANT
* would silently pin them to the room's participant tags forever
* (the "promote on demote" bug, audit #1).
*/
fun demoteToListener(
original: MeetingSpaceEvent,
targetPubkey: String,
): EventTemplate<MeetingSpaceEvent>? = setRole(original, targetPubkey, ROLE.PARTICIPANT)
): EventTemplate<MeetingSpaceEvent>? {
val existing = original.participants().firstOrNull { it.pubKey == targetPubkey } ?: return null
if (existing.role.equals(ROLE.PARTICIPANT.code, ignoreCase = true)) return null
return setRole(original, targetPubkey, ROLE.PARTICIPANT)
}
/**
* Drop [targetPubkey]'s p-tag from the room event entirely.
@@ -242,6 +242,12 @@ internal fun NestFullScreen(
reactionsByPubkey = reactionsByPubkey,
connectingSpeakers = ui.connectingSpeakers,
onLongPressParticipant = onLongPressParticipant,
// Tap on a stage avatar opens the same per-participant
// sheet as long-press, mirroring the audience grid.
// Long-press was previously the only entry point — a
// host with no awareness of the long-press affordance
// had no way to reach Promote / Demote / Kick.
onTapParticipant = onLongPressParticipant,
myPubkey = myPubkey,
onTapSelf = onTapSelf,
listenerCount = presences.size,
@@ -321,6 +327,7 @@ internal fun NestFullScreen(
event = event,
viewModel = viewModel,
accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant,
modifier =
Modifier
.weight(1f)
@@ -71,6 +71,7 @@ internal fun HandRaiseQueueSection(
viewModel: NestViewModel,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
onLongPressParticipant: ((String) -> Unit)? = null,
) {
val presences by viewModel.presences.collectAsState()
// Memoize the on-stage gate so a heartbeat that doesn't change
@@ -120,6 +121,11 @@ internal fun HandRaiseQueueSection(
template?.let { accountViewModel.account.signAndComputeBroadcast(it) }
}
},
// Tap or long-press on the avatar opens the
// per-participant sheet — without this, a host
// had to switch tabs to Audience to view profile
// or kick a hand-raiser they didn't want to approve.
onOpenParticipantSheet = onLongPressParticipant,
)
}
}
@@ -131,6 +137,7 @@ private fun HandRaiseRow(
hand: RoomPresence,
accountViewModel: AccountViewModel,
onApprove: () -> Unit,
onOpenParticipantSheet: ((String) -> Unit)? = null,
) {
val user = remember(hand.pubkey) { LocalCache.getOrCreateUser(hand.pubkey) }
Row(
@@ -142,6 +149,8 @@ private fun HandRaiseRow(
baseUserHex = hand.pubkey,
size = Size35dp,
accountViewModel = accountViewModel,
onClick = onOpenParticipantSheet,
onLongClick = onOpenParticipantSheet,
)
UsernameDisplay(
baseUser = user,
@@ -153,6 +153,7 @@ internal fun StageGrid(
reactionsByPubkey: Map<String, List<RoomReaction>> = emptyMap(),
connectingSpeakers: ImmutableSet<String> = persistentSetOf(),
onLongPressParticipant: ((String) -> Unit)? = null,
onTapParticipant: ((String) -> Unit)? = null,
myPubkey: String? = null,
onTapSelf: (() -> Unit)? = null,
listenerCount: Int = 0,
@@ -229,6 +230,7 @@ internal fun StageGrid(
reactions = reactionsByPubkey[member.pubkey].orEmpty(),
accountViewModel = accountViewModel,
onLongPressParticipant = onLongPressParticipant,
onTapParticipant = if (isSelf) null else onTapParticipant,
isSelf = isSelf,
onTapSelf = if (isSelf) onTapSelf else null,
modifier = Modifier.animateItem(),
+18
View File
@@ -679,6 +679,24 @@
<string name="nest_participant_unfollow">Unfollow</string>
<string name="nest_participant_mute">Mute</string>
<string name="nest_participant_unmute">Unmute</string>
<string name="nest_force_mute_note">May be ignored by clients that don\'t honor the verb.</string>
<string name="nest_confirm_kick_title">Kick from room?</string>
<string name="nest_confirm_kick_body">%1$s will be removed from the audio plane and dropped from the participant list. They can rejoin if they get the room link.</string>
<string name="nest_confirm_kick_confirm">Kick</string>
<string name="nest_confirm_force_mute_title">Force-mute speaker?</string>
<string name="nest_confirm_force_mute_body">Asks %1$s\'s client to mute its mic. Some clients may ignore this command.</string>
<string name="nest_confirm_force_mute_confirm">Force-mute</string>
<string name="nest_confirm_cancel">Cancel</string>
<string name="nest_toast_action_title">Audio room</string>
<string name="nest_toast_follow_sent">Following %1$s.</string>
<string name="nest_toast_unfollow_sent">Unfollowed %1$s.</string>
<string name="nest_toast_mute_sent">Muted %1$s.</string>
<string name="nest_toast_unmute_sent">Unmuted %1$s.</string>
<string name="nest_toast_promote_speaker_sent">Promoted %1$s to speaker.</string>
<string name="nest_toast_promote_moderator_sent">Promoted %1$s to moderator.</string>
<string name="nest_toast_demote_listener_sent">Demoted %1$s to listener.</string>
<string name="nest_toast_force_mute_sent">Force-muted %1$s.</string>
<string name="nest_toast_kick_sent">Kicked %1$s from the room.</string>
<string name="nest_share_action">Share room</string>
<string name="nest_minimize">Minimize</string>
<string name="nest_minimize_description">Minimize to keep listening</string>
@@ -106,6 +106,24 @@ class RoomParticipantActionsTest {
assertEquals(ROLE.MODERATOR.code, bobTag.getOrNull(3))
}
@Test
fun demotingAbsentTargetIsNoOp() {
// Audit #1: demote-to-listener used to fall through to setRole()
// which adds an absent target as PARTICIPANT — pinning a
// pure-audience listener to the participant list. After the
// fix, the call returns null instead.
val original = room()
assertNull(RoomParticipantActions.demoteToListener(original, alice))
}
@Test
fun demotingExistingParticipantIsNoOp() {
// Already a listener (PARTICIPANT role) — re-publishing with
// the same role is wasted relay traffic. Return null.
val original = room(listOf(arrayOf("p", alice, "", ROLE.PARTICIPANT.code)))
assertNull(RoomParticipantActions.demoteToListener(original, alice))
}
@Test
fun republishKeepsSameDTag() {
val original = room()