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
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
@@ -40,12 +43,19 @@ 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
@@ -54,27 +64,27 @@ 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 / moderator — hidden when the target already
* has that role (no wasted relay roundtrip).
* * Demote to listener — hidden when the target has no participant
* row or is already PARTICIPANT (see [RoomParticipantActions.demoteToListener]).
* * Force-mute — gated to currently-broadcasting speakers, and
* surfaced with a "may be ignored by other clients" note since
* not every client honors the verb (see [AdminCommandEvent.Action.MUTE]).
* * Kick — sends an AdminCommandEvent.kick plus a kind-30312
* republish that drops the target's p-tag. Confirms first.
* 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).
*
* 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
@@ -82,407 +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)
}
// 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) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
// Header: best-effort display name + short npub fallback
// (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,
)
}
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.
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.
AudienceActions(
target = target,
targetUser = targetUser,
accountViewModel = accountViewModel,
nestViewModel = nestViewModel,
toasts = toasts,
onZapRequested = { openDialog = SheetDialog.Zap },
onDismiss = onDismiss,
)
if (isLocalUserHost && target != event.pubKey) {
Spacer(Modifier.height(4.dp))
// Hide Promote/Demote rows when the target is already
// at that role — re-publishing kind-30312 with the same
// role string is wasted relay traffic and an obvious UX
// tell that the host doesn't know who they're acting on.
if (targetRole != ROLE.SPEAKER) {
ActionRow(stringRes(R.string.nest_promote_speaker)) {
broadcast(RoomParticipantActions.setRole(event, target, ROLE.SPEAKER))
toastAction(promoteSpeakerSentMsg)
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
}
HostActions(
target = target,
event = event,
accountViewModel = accountViewModel,
nestViewModel = nestViewModel,
toasts = toasts,
onForceMuteRequested = { openDialog = SheetDialog.ForceMute },
onKickRequested = { openDialog = SheetDialog.Kick },
onDismiss = onDismiss,
)
}
}
}
// Confirm dialogs render outside the bottom sheet so they overlay
// on top of it. Both fire-and-forget the broadcast then dismiss
// the whole sheet — the host gets a toast as positive confirmation
// since the actions are fire-and-forget through launchSigner and
// have no synchronous success signal.
if (showKickConfirm) {
AlertDialog(
onDismissRequest = { showKickConfirm = false },
title = { Text(stringRes(R.string.nest_confirm_kick_title)) },
text = { Text(stringRes(R.string.nest_confirm_kick_body, displayName)) },
confirmButton = {
TextButton(
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))
}
},
)
}
// 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()
},
)
}
if (showForceMuteConfirm) {
AlertDialog(
onDismissRequest = { showForceMuteConfirm = false },
title = { Text(stringRes(R.string.nest_confirm_force_mute_title)) },
text = { Text(stringRes(R.string.nest_confirm_force_mute_body, displayName)) },
confirmButton = {
TextButton(
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
onClick = {
showForceMuteConfirm = false
broadcast(AdminCommandEvent.forceMute(roomATag, target))
toastAction(forceMuteSentMsg)
onDismiss()
},
) {
Text(stringRes(R.string.nest_confirm_force_mute_confirm))
}
},
dismissButton = {
TextButton(onClick = { showForceMuteConfirm = false }) {
Text(stringRes(R.string.nest_confirm_cancel))
}
},
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(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(
@@ -496,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),
)