feat(nutzap): progress bar during send, matching lightning UX

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).
This commit is contained in:
Claude
2026-05-29 15:32:25 +00:00
parent 30a845a6c1
commit 4e18906f9a
4 changed files with 22 additions and 0 deletions
@@ -616,6 +616,7 @@ class CashuWalletOps(
zappedEvent: EventHintBundle<out Event>,
message: String,
available: List<TokenEntry>,
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,
@@ -1021,6 +1021,7 @@ class CashuWalletState(
recipientPubKey: HexKey,
zappedEvent: EventHintBundle<out Event>,
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,
)
}
@@ -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
},
@@ -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