From 4e18906f9aaefaee33ff85670b09abf733071a3c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 15:32:25 +0000 Subject: [PATCH] feat(nutzap): progress bar during send, matching lightning UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cashu zap chip previously fired its async send and closed the popup with no visible motion until the kind:9321 round-tripped back (1-2 seconds of "did the tap register?" silence). Wire the same zappingProgress channel the lightning path uses so the bar visibly animates from tap to completion. Pieces: - CashuWalletOps.sendNutzap: optional `onProgress: (Float) -> Unit` emitting at the network checkpoints — 0.55 after the swap (the long mint round-trip), 0.80 after the kind:9321 publish (the recipient can see it), 0.95 after keep + delete, 1.0 after the kind:7376 history. - CashuWalletState.sendNutzap: forwards onProgress to ops and also emits 0.20 after scrubLocallyStaleProofs so the bar moves before the slow swap call begins. - AccountViewModel.sendNutzap: accepts an `onProgress: (Float) -> Unit` parameter (default no-op so existing callers compile unchanged) and threads it through to CashuWalletState. - ReactionsRow.onNutzap callback: emits 0.05 immediately on tap for instant click feedback, then passes the same `onProgress` channel the lightning chip uses so zappingProgress drives the same progress indicator. The existing onError reset already zeroes the bar on failure. Resulting cadence at the UI: tap → 0.05 (instant) → 0.20 (scrub done) → 0.55 (mint swap done) → 0.80 (kind:9321 out) → 0.95 (token rollover + NIP-09 out) → 1.00 (history out, bar hides). --- .../amethyst/model/nip60Cashu/CashuWalletOps.kt | 9 +++++++++ .../amethyst/model/nip60Cashu/CashuWalletState.kt | 6 ++++++ .../com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 5 +++++ .../amethyst/ui/screen/loggedIn/AccountViewModel.kt | 2 ++ 4 files changed, 22 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt index 69451f36e0..886e6ee1ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletOps.kt @@ -616,6 +616,7 @@ class CashuWalletOps( zappedEvent: EventHintBundle, message: String, available: List, + onProgress: ((Float) -> Unit)? = null, ): NutzapSent { if (amountSats <= 0) throw IllegalArgumentException("Amount must be positive") seedWarmer() @@ -628,6 +629,10 @@ class CashuWalletOps( recipientP2pkPubkeyHex = recipientP2pkPubkeyHex, targetSplit = amountSats, ) + // Mint round-trip done — biggest chunk of the wall clock. Surface + // visible motion here so the user knows the click registered even + // before the recipient sees the kind:9321. + onProgress?.invoke(0.55f) // Build the kind:9321 first so we have its id to reference from history. val proofJsons = swap.send.map { nutzapProofJson.encodeToString(NutzapProofJson.serializer(), it.toNutzapJson()) } @@ -642,6 +647,8 @@ class CashuWalletOps( ) val nutzapEvent = signer.sign(nutzapTemplate) publish(nutzapEvent) + // kind:9321 is out — recipient's auto-redeem can fire from here. + onProgress?.invoke(0.80f) // Roll over change locally if any. val keepEvent = @@ -663,6 +670,7 @@ class CashuWalletOps( publish(it) } } + onProgress?.invoke(0.95f) val historyTemplate = CashuSpendingHistoryEvent.build( @@ -677,6 +685,7 @@ class CashuWalletOps( ) val historyEvent = signer.sign(historyTemplate) publish(historyEvent) + onProgress?.invoke(1.0f) return NutzapSent( nutzapEvent = nutzapEvent, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt index c2c2921bed..4e6f296435 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip60Cashu/CashuWalletState.kt @@ -1021,6 +1021,7 @@ class CashuWalletState( recipientPubKey: HexKey, zappedEvent: EventHintBundle, message: String = "", + onProgress: ((Float) -> Unit)? = null, ): NutzapSent { check(started) { "CashuWalletState.start() not called" } val target = @@ -1033,6 +1034,10 @@ class CashuWalletState( // partial-failure persists until the user clicks send. Heal // first so the selection below works on a known-fresh view. scrubLocallyStaleProofs(target.mintUrl) + // First on-network step done. The caller already showed an + // instant 0.05 when the chip was tapped; lift to 0.20 here so + // the bar visibly moves even before the (slow) swap call. + onProgress?.invoke(0.20f) val available = _tokenEntries.value.filter { it.content.mint == target.mintUrl } if (available.isEmpty()) { @@ -1047,6 +1052,7 @@ class CashuWalletState( zappedEvent = zappedEvent, message = message, available = available, + onProgress = onProgress, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 54cc96c0be..65abc41c87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2012,11 +2012,16 @@ fun ZapAmountChoicePopup( nutzapAmountChoices = if (nutzapEnabled) zapAmountChoices else persistentListOf(), onNutzap = { amountInSats -> onZapStarts() + // Instant click feedback. Without this initial nudge + // the bar stays flat for ~1s until scrubLocallyStale- + // Proofs returns; the user thinks the tap was lost. + onProgress(0.05f) accountViewModel.sendNutzap( baseNote = baseNote, amountSats = amountInSats, message = "", onError = onError, + onProgress = onProgress, ) visibilityState.targetState = false }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index cb40bb677e..1c254fc462 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -923,6 +923,7 @@ class AccountViewModel( amountSats: Long, message: String, onError: (String, String, User?) -> Unit, + onProgress: (Float) -> Unit = {}, ) = launchSigner { val recipient = baseNote.author?.pubkeyHex if (recipient == null) { @@ -948,6 +949,7 @@ class AccountViewModel( recipientPubKey = recipient, zappedEvent = zappedEvent, message = message, + onProgress = onProgress, ) // No success toast — the kind:9321 round-trips through the // cache, attaches to the target Note via addNutzap, and the