fix(nests): clear zap spinner on wallet handoff; move overlay off the hand badge

NestZapButton had no ObserveZapIconState fallback (unlike NoteCompose's
ZapReaction), so after onPayViaIntent handed the invoice to an external
wallet the progress spinner stayed up indefinitely in the long-lived
NestActivity. Reset zappingProgress to 0 on every onPayViaIntent path.

The floating zap overlay was anchored TopEnd, colliding with the
hand-raise badge — and since the merge made zaps float from the
zapper's avatar (a likely hand-raiser), that overlap would be common.
Moved it to TopCenter, the only badge-free anchor.

Also dropped dead state (zapStartingTime / the non-animated
animatedProgress alias) and corrected stale comments left over from
the sender-grouping merge.

https://claude.ai/code/session_01EW11kUdiEYPuPti7vtD2AR
This commit is contained in:
Claude
2026-05-14 13:19:44 +00:00
parent f45479615e
commit 4a3cea85cb
3 changed files with 49 additions and 27 deletions
@@ -54,7 +54,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -83,7 +82,6 @@ import com.vitorpamplona.amethyst.ui.note.zapClick
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.reactions.RoomReactionPopup
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.uuid.ExperimentalUuidApi
@@ -457,11 +455,15 @@ private fun NestZapButton(
var wantsToZap by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
// 0f when idle. Driven 0f → 1f by the zap flow's progress
// callbacks; reset to 0f on error, on dismiss, and when the flow
// hands off to an external wallet via `onPayViaIntent` (the
// button can't observe payment completion past that handoff, so
// it must not leave the spinner up — unlike NoteCompose's
// ZapReaction, this button has no ObserveZapIconState fallback).
var zappingProgress by remember { mutableFloatStateOf(0f) }
var zapStartingTime by remember { mutableLongStateOf(0L) }
val animatedProgress = zappingProgress
val isZapping = animatedProgress > 0.00001f && animatedProgress < 0.99999f
val isZapping = zappingProgress > 0.00001f && zappingProgress < 0.99999f
FilledTonalIconButton(
onClick = {
@@ -470,7 +472,7 @@ private fun NestZapButton(
baseNote = roomNote,
accountViewModel = accountViewModel,
context = context,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onZapStarts = {},
onZappingProgress = { progress -> scope.launch { zappingProgress = progress } },
onMultipleChoices = { scope.launch { wantsToZap = true } },
onError = { _, message, user ->
@@ -480,6 +482,11 @@ private fun NestZapButton(
}
},
onPayViaIntent = {
// Handing off to an external wallet (or the
// manual-split screen) ends the in-app phase —
// clear the spinner since nothing downstream
// tracks payment completion for this button.
zappingProgress = 0f
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
@@ -502,7 +509,7 @@ private fun NestZapButton(
) {
if (isZapping) {
CircularProgressIndicator(
progress = { animatedProgress },
progress = { zappingProgress },
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
@@ -519,7 +526,7 @@ private fun NestZapButton(
baseNote = roomNote,
popupYOffset = 48.dp,
accountViewModel = accountViewModel,
onZapStarts = { zapStartingTime = TimeUtils.now() },
onZapStarts = {},
onDismiss = {
wantsToZap = false
zappingProgress = 0f
@@ -538,6 +545,11 @@ private fun NestZapButton(
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
onPayViaIntent = {
// Handing off to an external wallet (or the manual-
// split screen) ends the in-app phase — clear the
// spinner since nothing downstream tracks payment
// completion for this button.
zappingProgress = 0f
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
@@ -558,7 +570,7 @@ private fun NestZapButton(
if (wantsToSetCustomZap) {
ZapCustomDialog(
onZapStarts = { zapStartingTime = TimeUtils.now() },
onZapStarts = {},
onClose = { wantsToSetCustomZap = false },
onError = { _, message, user ->
scope.launch {
@@ -568,6 +580,11 @@ private fun NestZapButton(
},
onProgress = { scope.launch(Dispatchers.Main) { zappingProgress = it } },
onPayViaIntent = {
// Handing off to an external wallet (or the manual-
// split screen) ends the in-app phase — clear the
// spinner since nothing downstream tracks payment
// completion for this button.
zappingProgress = 0f
if (it.size == 1) {
val payable = it.first()
payViaIntent(payable.invoice, context, { }) { error ->
@@ -561,21 +561,24 @@ private fun MemberCell(
.offset(x = -ringPadding + 6.dp, y = -ringPadding + 6.dp),
)
}
// Zaps animate in the avatar's TOP-right corner — same
// outer-Box sibling placement and reasoning as the
// reaction overlay above, just the opposite vertical
// corner so the two streams never stack on each other
// (reactions BottomEnd, mic badge BottomCenter). End-
// anchored like reactions so the chip extends LEFTWARD
// into the cell as `⚡ Nsats` widens, rather than bleeding
// into the neighbouring column.
// Zaps animate just above the avatar, TOP-CENTER — the
// only badge-free anchor: the role badge sits TopStart,
// the hand-raise badge TopEnd, the mic badge BottomCenter
// and the reaction overlay BottomEnd. Anchoring TopEnd
// would collide with the hand-raise badge, and now that
// zaps float from the *zapper's* avatar (a likely
// hand-raiser), that overlap would be common. Center-
// anchored so the `⚡ Nsats` chip extends symmetrically
// and stays within the cell rather than bleeding into the
// neighbouring column. Same outer-Box sibling placement
// and layout-stability reasoning as the reaction overlay.
if (zaps.isNotEmpty()) {
SpeakerZapOverlay(
zaps = zaps,
modifier =
Modifier
.align(Alignment.TopEnd)
.offset(x = -ringPadding + 6.dp, y = ringPadding - 6.dp),
.align(Alignment.TopCenter)
.offset(y = ringPadding - 6.dp),
)
}
}
@@ -53,11 +53,13 @@ import kotlinx.coroutines.delay
/**
* Floating zap-chip overlay drawn over a participant's avatar — the
* zap counterpart to [SpeakerReactionOverlay]. Each chip is keyed by
* the zap event id; consecutive zaps to the same target stack into a
* row, with the chip life-cycle (`fadeIn + scaleIn` on arrival,
* upward drift + `fadeOut` over [REACTION_WINDOW_SEC]) matching
* reactions so both streams visually feel like the same animation.
* zap counterpart to [SpeakerReactionOverlay]. The aggregator groups
* zaps by sender, so this renders the zaps a single participant has
* *sent*: consecutive zaps from that sender stack into a row, each
* chip keyed by its event id. The chip life-cycle (`fadeIn + scaleIn`
* on arrival, upward drift + `fadeOut` over [REACTION_WINDOW_SEC])
* matches reactions so both streams visually feel like the same
* animation.
*
* Renders an "⚡ Nsats" pill in [BitcoinOrange] so zaps are distinct
* from emoji reactions at a glance.
@@ -141,7 +143,7 @@ private fun ZapChip(zap: RoomZap) {
private const val ZAP_WINDOW_MS = REACTION_WINDOW_SEC * 1000L
// Cap the row at a small number so a burst of zaps to the same
// participant doesn't overflow the avatar's bottom-right corner —
// the eviction sweep clears them within the window anyway.
// Cap the row at a small number so a burst of zaps from the same
// sender doesn't overflow the avatar's top-center anchor — the
// eviction sweep clears them within the window anyway.
private const val MAX_VISIBLE_ZAPS = 3