From 5bda97db04177a425389a62f8ed8afcfba4ec756 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 20:43:56 +0000 Subject: [PATCH 1/2] fix(nests): promote/demote/kick actions no longer cancelled by sheet dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-participant host actions (Promote to Speaker, Promote to Moderator, Demote, Force Mute, Kick) and the Hand-Raise queue's Approve button all launched their sign+broadcast on a rememberCoroutineScope() bound to the local composable. Because every action row calls onDismiss() (and the hand-raise row may disappear when canSpeak() flips true), the composable leaves the tree synchronously after the click, cancelling the scope before the launched coroutine ever runs sign(template). The user saw the sheet close and nothing else — the kind-30312 republish / kind-4312 admin event was never actually emitted. Launch on accountViewModel.viewModelScope instead so the broadcast outlives the dismissing UI. --- .../room/participants/ParticipantHostActionsSheet.kt | 12 +++++++++--- .../nests/room/stage/HandRaiseQueueSection.kt | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt index 8c76c30b40..c2dc3268b6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt @@ -37,12 +37,12 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage @@ -88,7 +88,6 @@ internal fun ParticipantHostActionsSheet( catalog: com.vitorpamplona.amethyst.commons.viewmodels.RoomSpeakerCatalog? = null, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() val roomATag = ATag( @@ -98,9 +97,16 @@ internal fun ParticipantHostActionsSheet( relay = null, ) + // Use the AccountViewModel's scope so the launched broadcast + // survives the onDismiss() that every action row triggers. A + // composition-scoped rememberCoroutineScope() gets cancelled the + // moment hostMenuTarget flips to null and the sheet leaves the + // tree — which races (and reliably loses) against the suspending + // signer.sign(template) call, so the promote/demote/kick events + // never actually went out. fun broadcast(template: com.vitorpamplona.quartz.nip01Core.signers.EventTemplate?) { template ?: return - scope.launch { runCatching { accountViewModel.account.signAndComputeBroadcast(template) } } + accountViewModel.viewModelScope.launch { runCatching { accountViewModel.account.signAndComputeBroadcast(template) } } } val targetUser = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt index 2d1421f82d..a4e6baef20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt @@ -37,10 +37,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.commons.viewmodels.RoomPresence @@ -96,7 +96,6 @@ internal fun HandRaiseQueueSection( if (hands.isEmpty()) return - val scope = rememberCoroutineScope() Column(modifier = modifier.fillMaxSize().padding(top = 12.dp)) { Text( text = stringRes(R.string.nest_hand_raise_queue_title), @@ -112,7 +111,14 @@ internal fun HandRaiseQueueSection( hand = hand, accountViewModel = accountViewModel, onApprove = { - scope.launch { + // Approving makes `canSpeak()` true for this user, which + // removes them from `hands` on the next recompose. If the + // queue empties (no other raised hands), the whole + // section leaves composition — a rememberCoroutineScope() + // bound here would cancel the in-flight signer.sign(...) + // and the promote never goes out. Launch on the + // AccountViewModel's scope so signing outlives the row. + accountViewModel.viewModelScope.launch { val template = RoomParticipantActions.setRole(event, hand.pubkey, ROLE.SPEAKER) template?.let { runCatching { accountViewModel.account.signAndComputeBroadcast(it) } } } From c0710b794f874ceeb0ba8a4f2b852d8d4885cf14 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 11 May 2026 21:02:57 +0000 Subject: [PATCH 2/2] refactor(nests): route host actions through AccountViewModel.launchSigner Replace the raw viewModelScope.launch + runCatching with launchSigner, matching the rest of the codebase. Same survival-past-dismiss guarantee, plus signer errors (read-only key, NIP-46 unauthorized / timeout, signer not found) now surface as toasts instead of being silently swallowed. --- .../ParticipantHostActionsSheet.kt | 15 +++++---------- .../nests/room/stage/HandRaiseQueueSection.kt | 19 ++++++++----------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt index c2dc3268b6..8ff9d05cbf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt @@ -42,7 +42,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage @@ -56,7 +55,6 @@ 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 -import kotlinx.coroutines.launch /** * Per-participant context sheet (T2 #2). Always shows the @@ -97,16 +95,13 @@ internal fun ParticipantHostActionsSheet( relay = null, ) - // Use the AccountViewModel's scope so the launched broadcast - // survives the onDismiss() that every action row triggers. A - // composition-scoped rememberCoroutineScope() gets cancelled the - // moment hostMenuTarget flips to null and the sheet leaves the - // tree — which races (and reliably loses) against the suspending - // signer.sign(template) call, so the promote/demote/kick events - // never actually went out. + // 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?) { template ?: return - accountViewModel.viewModelScope.launch { runCatching { accountViewModel.account.signAndComputeBroadcast(template) } } + accountViewModel.launchSigner { accountViewModel.account.signAndComputeBroadcast(template) } } val targetUser = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt index a4e6baef20..bfb741a6a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/stage/HandRaiseQueueSection.kt @@ -40,7 +40,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.commons.viewmodels.RoomPresence @@ -53,7 +52,6 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ROLE -import kotlinx.coroutines.launch /** * Host-only queue of audience members whose latest kind-10312 @@ -111,16 +109,15 @@ internal fun HandRaiseQueueSection( hand = hand, accountViewModel = accountViewModel, onApprove = { - // Approving makes `canSpeak()` true for this user, which - // removes them from `hands` on the next recompose. If the - // queue empties (no other raised hands), the whole - // section leaves composition — a rememberCoroutineScope() - // bound here would cancel the in-flight signer.sign(...) - // and the promote never goes out. Launch on the - // AccountViewModel's scope so signing outlives the row. - accountViewModel.viewModelScope.launch { + // launchSigner runs on viewModelScope (+ Dispatchers.IO) + // and surfaces signer errors as toasts. Composition-scoped + // alternatives get cancelled when this row leaves the tree: + // approving flips `canSpeak()` true, the hand is filtered + // out, and if it was the last hand the section disposes + // entirely — killing the in-flight sign(...) before it ran. + accountViewModel.launchSigner { val template = RoomParticipantActions.setRole(event, hand.pubkey, ROLE.SPEAKER) - template?.let { runCatching { accountViewModel.account.signAndComputeBroadcast(it) } } + template?.let { accountViewModel.account.signAndComputeBroadcast(it) } } }, )