refactor(nests): decompose ParticipantHostActionsSheet, tighten internals

Self-review cleanup of the previous fix commit. No behavior change:

  1. Removed nullability from the `nestViewModel` parameter — the only
     call site always passes it, and the previous chain
     `nestViewModel?.uiState?.collectAsState()?.value` was a
     conditional composable that only worked by accident.
  2. Replaced the hand-rolled `targetCanSpeakByRole` with
     `ParticipantTag.canSpeak()` so future role additions stay
     consistent.
  3. Split the monolithic sheet into ParticipantSheetHeader,
     AudienceActions, HostActions, ParticipantZapDialog and
     DestructiveConfirmDialog so each piece recomposes on its own
     state and has a single responsibility.
  4. Factored the `broadcast(...) + toast + onDismiss()` triplet into
     a `hostAction(...)` helper inside HostActions and a top-level
     `broadcast(...)` utility for the destructive dialogs.
  5. Collapsed the 13 pre-resolved toast strings into a
     `ParticipantToasts` data class produced once by
     `rememberParticipantToasts(displayName)`.
  6. Made displayName reactive via `observeUserInfo` so the header
     and toast text always agree (previously the toast used a
     snapshot while the header observed the metadata flow).
  7. Replaced the deeply qualified types in the signature with
     normal imports.
  8. Consolidated three `rememberSaveable<Boolean>` dialog flags
     into a single `SheetDialog` enum with a name-based Saver, so
     the dialog states are mutually exclusive by construction.

https://claude.ai/code/session_013Aj1h3epWkvucKFLdunjcv
This commit is contained in:
Claude
2026-05-12 13:26:14 +00:00
parent 24a87602a7
commit 9fc0dda144
@@ -20,6 +20,9 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.participants 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -40,12 +43,19 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R 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.MainActivity
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -54,27 +64,27 @@ import com.vitorpamplona.amethyst.ui.note.payViaIntent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.nests.admin.AdminCommandEvent 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.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE
/** /**
* Per-participant context sheet (T2 #2). Always shows the * Per-participant context sheet (T2 #2). Long-press on any participant
* audience-friendly rows (View profile / Follow-Unfollow / Mute); * avatar — or single-tap on an audience / hand-raise avatar — opens
* appends the host management rows (Promote / Demote / Kick) when * this sheet.
* the local user is the room's host.
* *
* * Promote to speaker / moderator — hidden when the target already * Top-level layout: a [ParticipantSheetHeader] followed by
* has that role (no wasted relay roundtrip). * [AudienceActions] (View profile / Zap / Follow / Mute / Local hush)
* * Demote to listener — hidden when the target has no participant * and, when the local user is the host, [HostActions]
* row or is already PARTICIPANT (see [RoomParticipantActions.demoteToListener]). * (Promote / Demote / Force-mute / Kick).
* * Force-mute — gated to currently-broadcasting speakers, and *
* surfaced with a "may be ignored by other clients" note since * Destructive host actions (Kick, Force-mute) and the Zap flow are
* not every client honors the verb (see [AdminCommandEvent.Action.MUTE]). * confirmed via a top-level [SheetDialog] state so the sub-composables
* * Kick — sends an AdminCommandEvent.kick plus a kind-30312 * never own dialog window state themselves.
* republish that drops the target's p-tag. Confirms first.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -82,407 +92,386 @@ internal fun ParticipantHostActionsSheet(
target: String, target: String,
event: MeetingSpaceEvent, event: MeetingSpaceEvent,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nestViewModel: NestViewModel,
onDismiss: () -> Unit, onDismiss: () -> Unit,
nestViewModel: com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel? = null,
isLocalUserHost: Boolean = accountViewModel.account.signer.pubKey == event.pubKey, 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 sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val targetUser = remember(target) { LocalCache.getOrCreateUser(target) }
val roomATag = // Reactive display name — UsernameDisplay observes this for the
ATag( // header, and we reuse the same flow for toast text so the header
kind = event.kind, // and toast never disagree (e.g. metadata arriving mid-sheet).
pubKeyHex = event.pubKey, val userInfo by observeUserInfo(targetUser, accountViewModel)
dTag = event.dTag(), val displayName = userInfo?.info?.bestName() ?: targetUser.pubkeyDisplayHex()
relay = null, val toasts = rememberParticipantToasts(displayName)
)
// Go through accountViewModel.launchSigner so the broadcast runs on var openDialog by rememberSaveable(stateSaver = SheetDialogSaver) {
// viewModelScope (survives onDismiss() removing the sheet from mutableStateOf<SheetDialog?>(null)
// 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) }
} }
val targetUser =
remember(target) {
com.vitorpamplona.amethyst.model.LocalCache
.getOrCreateUser(target)
}
// 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` (the previous
// pattern) left stale labels: opening the sheet after a follow
// request still showed "Follow".
val followState by accountViewModel.account.kind3FollowList.flow
.collectAsState()
val hiddenState by accountViewModel.account.hiddenUsers.flow
.collectAsState()
val isFollowing = target in followState.authors
val isHidden = target in hiddenState.hiddenUsers
// Resolve the target's current role from the kind-30312
// participant list. Drives both the visibility of Promote /
// Demote rows (hidden when the target is already at that role)
// and the Force-mute gate (only meaningful for someone with the
// floor). null = pure-audience listener with no participant tag.
val targetRole =
remember(event, target) {
event.participants().firstOrNull { it.pubKey == target }?.effectiveRole()
}
val targetCanSpeakByRole =
targetRole == ROLE.HOST || targetRole == ROLE.MODERATOR || targetRole == ROLE.SPEAKER
val nestUi = nestViewModel?.uiState?.collectAsState()?.value
val nestPresences = nestViewModel?.presences?.collectAsState()?.value
val targetIsBroadcasting = nestPresences?.get(target)?.publishing == true
// Pre-resolve display name + toast templates so non-composable
// callbacks (the row onClicks) can format strings without
// calling stringRes() from outside a @Composable scope. The
// sheet is short-lived, so we snapshot once at composition; if
// metadata arrives mid-sheet, the toast falls back to the
// truncated npub — same as the in-feed UsernameDisplay would
// show before observe-flow emission.
val displayName = remember(targetUser) { targetUser.toBestDisplayName() }
val toastTitleResId = R.string.nest_toast_action_title
val followSentMsg = stringRes(R.string.nest_toast_follow_sent, displayName)
val unfollowSentMsg = stringRes(R.string.nest_toast_unfollow_sent, displayName)
val muteSentMsg = stringRes(R.string.nest_toast_mute_sent, displayName)
val unmuteSentMsg = stringRes(R.string.nest_toast_unmute_sent, displayName)
val promoteSpeakerSentMsg = stringRes(R.string.nest_toast_promote_speaker_sent, displayName)
val promoteModeratorSentMsg = stringRes(R.string.nest_toast_promote_moderator_sent, displayName)
val demoteListenerSentMsg = stringRes(R.string.nest_toast_demote_listener_sent, displayName)
val forceMuteSentMsg = stringRes(R.string.nest_toast_force_mute_sent, displayName)
val kickSentMsg = stringRes(R.string.nest_toast_kick_sent, displayName)
val toastTitle = stringRes(toastTitleResId)
val noAppMessage = stringRes(R.string.nest_no_app_to_open_link)
val splitUnsupportedMsg = stringRes(R.string.nest_participant_zap_split_unsupported)
fun toastAction(message: String) {
accountViewModel.toastManager.toast(toastTitle, message)
}
var showZapDialog by rememberSaveable { mutableStateOf(false) }
var showKickConfirm by rememberSaveable { mutableStateOf(false) }
var showForceMuteConfirm by rememberSaveable { mutableStateOf(false) }
val context = LocalContext.current
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column( Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
// Header: best-effort display name + short npub fallback ParticipantSheetHeader(targetUser, catalog, accountViewModel)
// (UsernameDisplay handles "no metadata yet" by rendering
// the truncated npub itself). The previous hex stub
// `target.take(8) + "…"` was unreadable for non-technical
// users — they had no idea who they were about to mute.
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,
)
}
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// View Profile — NestActivity is a separate Android AudienceActions(
// Activity without its own nav stack, so the deep-link target = target,
// path goes through MainActivity. Launching `nostr:npub1...` targetUser = targetUser,
// via ACTION_VIEW lets the existing MainActivity URI accountViewModel = accountViewModel,
// handler route to Route.Profile (matches the path that nestViewModel = nestViewModel,
// an external app or a clicked link inside the feed toasts = toasts,
// would take). NEW_TASK + REORDER_TO_FRONT brings the onZapRequested = { openDialog = SheetDialog.Zap },
// already-running MainActivity instance forward; the onDismiss = onDismiss,
// audio-room foreground service keeps audio alive while )
// the user is on the profile screen.
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.
ActionRow(stringRes(R.string.nest_participant_zap)) {
showZapDialog = true
}
if (showZapDialog) {
val targetMetadataNote =
remember(target) {
com.vitorpamplona.amethyst.model.LocalCache
.getOrCreateAddressableNote(MetadataEvent.createAddress(target))
}
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),
)
}
},
accountViewModel = accountViewModel,
baseNote = targetMetadataNote,
)
}
ActionRow(
text =
stringRes(
if (isFollowing) {
R.string.nest_participant_unfollow
} else {
R.string.nest_participant_follow
},
),
) {
if (isFollowing) {
accountViewModel.unfollow(targetUser)
toastAction(unfollowSentMsg)
} else {
accountViewModel.follow(targetUser)
toastAction(followSentMsg)
}
onDismiss()
}
ActionRow(
text =
stringRes(
if (isHidden) {
R.string.nest_participant_unmute
} else {
R.string.nest_participant_mute
},
),
) {
if (isHidden) {
accountViewModel.show(targetUser)
toastAction(unmuteSentMsg)
} else {
accountViewModel.hide(targetUser)
toastAction(muteSentMsg)
}
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 is a silent no-op — the row was misleading.
if (
nestViewModel != null &&
target != accountViewModel.account.signer.pubKey &&
targetIsBroadcasting
) {
val isHushedLocally = target in (nestUi?.locallyHushed ?: emptySet())
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) { if (isLocalUserHost && target != event.pubKey) {
Spacer(Modifier.height(4.dp)) HostActions(
// Hide Promote/Demote rows when the target is already target = target,
// at that role — re-publishing kind-30312 with the same event = event,
// role string is wasted relay traffic and an obvious UX accountViewModel = accountViewModel,
// tell that the host doesn't know who they're acting on. nestViewModel = nestViewModel,
if (targetRole != ROLE.SPEAKER) { toasts = toasts,
ActionRow(stringRes(R.string.nest_promote_speaker)) { onForceMuteRequested = { openDialog = SheetDialog.ForceMute },
broadcast(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER)) onKickRequested = { openDialog = SheetDialog.Kick },
toastAction(promoteSpeakerSentMsg) onDismiss = onDismiss,
onDismiss() )
}
}
if (targetRole != ROLE.MODERATOR) {
ActionRow(stringRes(R.string.nest_promote_moderator)) {
broadcast(RoomParticipantActions.setRole(event, target, ROLE.MODERATOR))
toastAction(promoteModeratorSentMsg)
onDismiss()
}
}
// Demote only when the target actually has a role
// above listener — otherwise the call is a no-op (see
// RoomParticipantActions.demoteToListener) and the
// visible row sets up a misleading expectation.
if (targetRole != null && targetRole != ROLE.PARTICIPANT) {
ActionRow(stringRes(R.string.nest_demote_listener)) {
broadcast(RoomParticipantActions.demoteToListener(event, target))
toastAction(demoteListenerSentMsg)
onDismiss()
}
}
// Force-mute is honor-based: the target's client must
// honor the verb. Hidden unless the target is a current
// speaker (by role) AND broadcasting right now — the
// command has nothing to mute otherwise. A subtitle
// surfaces the "may be ignored" caveat so the host
// doesn't expect a hard mute against non-Amethyst
// clients.
if (targetCanSpeakByRole && targetIsBroadcasting) {
ActionRow(
text = stringRes(R.string.nest_force_mute),
color = MaterialTheme.colorScheme.error,
) {
showForceMuteConfirm = true
}
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,
) {
showKickConfirm = true
}
} }
} }
} }
// Confirm dialogs render outside the bottom sheet so they overlay // Top-level dialog dispatch so the sub-composables never own
// on top of it. Both fire-and-forget the broadcast then dismiss // window state and the destructive actions live next to the
// the whole sheet — the host gets a toast as positive confirmation // confirm dialog they belong to.
// since the actions are fire-and-forget through launchSigner and when (openDialog) {
// have no synchronous success signal. SheetDialog.Zap -> {
if (showKickConfirm) { ParticipantZapDialog(
AlertDialog( target = target,
onDismissRequest = { showKickConfirm = false }, accountViewModel = accountViewModel,
title = { Text(stringRes(R.string.nest_confirm_kick_title)) }, zapSplitUnsupported = toasts.zapSplitUnsupported,
text = { Text(stringRes(R.string.nest_confirm_kick_body, displayName)) }, onClose = {
confirmButton = { openDialog = null
TextButton( onDismiss()
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), },
onClick = { )
showKickConfirm = false }
// 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))
toastAction(kickSentMsg)
onDismiss()
},
) {
Text(stringRes(R.string.nest_confirm_kick_confirm))
}
},
dismissButton = {
TextButton(onClick = { showKickConfirm = false }) {
Text(stringRes(R.string.nest_confirm_cancel))
}
},
)
}
if (showForceMuteConfirm) { SheetDialog.Kick -> {
AlertDialog( DestructiveConfirmDialog(
onDismissRequest = { showForceMuteConfirm = false }, title = stringRes(R.string.nest_confirm_kick_title),
title = { Text(stringRes(R.string.nest_confirm_force_mute_title)) }, body = stringRes(R.string.nest_confirm_kick_body, displayName),
text = { Text(stringRes(R.string.nest_confirm_force_mute_body, displayName)) }, confirmLabel = stringRes(R.string.nest_confirm_kick_confirm),
confirmButton = { onConfirm = {
TextButton( openDialog = null
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), val roomATag = ATag(event.kind, event.pubKey, event.dTag(), null)
onClick = { // Two-step kick mirroring nostrnests' ProfileCard.tsx:
showForceMuteConfirm = false // 1. ephemeral kind-4312 ["action","kick"] kicks the
broadcast(AdminCommandEvent.forceMute(roomATag, target)) // user off the audio plane
toastAction(forceMuteSentMsg) // 2. re-published kind-30312 with the target's p-tag
onDismiss() // dropped removes them from the participant grid
}, // so future presence events don't re-render them
) { broadcast(accountViewModel, AdminCommandEvent.kick(roomATag, target))
Text(stringRes(R.string.nest_confirm_force_mute_confirm)) broadcast(accountViewModel, RoomParticipantActions.removeParticipant(event, target))
} accountViewModel.toastManager.toast(toasts.title, toasts.kickSent)
}, onDismiss()
dismissButton = { },
TextButton(onClick = { showForceMuteConfirm = false }) { onDismiss = { openDialog = null },
Text(stringRes(R.string.nest_confirm_cancel)) )
} }
},
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 @Composable
private fun ActionRow( private fun ActionRow(
text: String, text: String,
color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Unspecified, color: Color = Color.Unspecified,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
Text( Text(
@@ -496,3 +485,93 @@ private fun ActionRow(
.padding(vertical = 12.dp), .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),
)