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