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:
+466
-387
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user