From d654e2d1cb76aa4d1032db2c424b7937e44b9429 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 10 Sep 2026 21:53:46 -0700 Subject: The zap chip lands on the tap and pulses until the payment settles. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zap took a few seconds to pay and its public receipt trailed that by seconds more, so the tally chip appeared long after the tap — the tap looked ignored, and the only way to tell it had worked was the toast. Now the chip appears on the tap carrying your amount, breathing while the payment is in flight; it goes solid the moment the payment lands, and steps aside when the real receipt arrives (which is also how it avoids counting your zap twice). The ark and Lightning one-tap flows report their outcome back — a failure, or a fallback into the form, takes the chip straight off again rather than leaving it pulsing. An unreported zap gives up after 45s. A zap sent from the form reports too, so the tally shows it right away instead of waiting on a relay. Reduced-motion gets a dimmed chip, no pulse. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx --- src/features/ark.js | 21 ++++++++++++---- src/features/messages.js | 65 ++++++++++++++++++++++++++++++++++++++++++------ src/features/zaps.js | 6 ++++- src/i18n.js | 1 + src/style.css | 9 +++++++ 5 files changed, 88 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/features/ark.js b/src/features/ark.js index 251f740..8e74cc6 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -1499,16 +1499,26 @@ export function arkFeature(ctx) { ui.sendError = ''; render(); const live = () => ui.arkZap === z; + // The tap already put a pulsing chip on the note being zapped: whichever + // way this goes, say so, so the chip settles instead of pulsing until it + // times out. (A handover to the Lightning flow doesn't report — that flow + // is still carrying the same zap, and reports for itself.) + const bail = () => { + if (!z.autoSat) return; // a form zap: the card keeps the screen and its own status + ctx.hook('zapSettled', z.eventId, false); + z.autoSat = 0; ui.tab = 'send'; + }; // an instant zap: resolution succeeded — pay the default amount now, no // form, and report by toast; failures fall back to the classic form const auto = async (label) => { try { await performArkZap(z, z.autoSat); if (ui.arkZap === z) ui.arkZap = null; + ctx.hook('zapSettled', z.eventId, true, z.autoSat); toast('⚡ ' + t('zapSentShort', { n: fmtAmount(z.autoSat) + ' ' + unitLabel() })); render(); } catch (e) { - z.autoSat = 0; ui.tab = 'send'; ui.sendError = e.message; render(); + bail(); ui.sendError = e.message; render(); } }; (async () => { @@ -1517,7 +1527,7 @@ export function arkFeature(ctx) { const adv = await lookupArkZapTarget(pk).catch(() => ({ status: 'noark' })); if (!live()) return; if (adv.status === 'ready') { Object.assign(z, adv); if (z.autoSat) return auto(); render(); return; } - if (adv.status === 'wrongnet') { Object.assign(z, adv); if (z.autoSat) { z.autoSat = 0; ui.tab = 'send'; } render(); return; } + if (adv.status === 'wrongnet') { Object.assign(z, adv); bail(); render(); return; } // 1b. BIP-353 with an ark instruction; remember on-chain as last resort const uris = []; try { @@ -1557,12 +1567,12 @@ export function arkFeature(ctx) { if (onchain) { ui.arkZap = null; ui.send.recipients[0].address = onchain; - if (z.autoSat) ui.tab = 'send'; + bail(); render(); return; } - if (live()) { z.status = 'noark'; if (z.autoSat) { z.autoSat = 0; ui.tab = 'send'; } render(); } - })().catch((e) => { if (live()) { z.status = 'noark'; if (z.autoSat) { z.autoSat = 0; ui.tab = 'send'; } ui.sendError = e.message; render(); } }); + if (live()) { z.status = 'noark'; bail(); render(); } + })().catch((e) => { if (live()) { z.status = 'noark'; bail(); ui.sendError = e.message; render(); } }); } // shared by the ark and lightning zap flows (see zaps.js for the twin) @@ -1614,6 +1624,7 @@ export function arkFeature(ctx) { ui.busy = true; ui.sendError = ''; render(); try { await performArkZap(z, sats); + ctx.hook('zapSettled', z.eventId, true, sats); // the tally shows it now, not when the receipt lands ui.arkZapped = { amountSat: sats, npub: z.npub }; ui.arkZap = null; } catch (e) { diff --git a/src/features/messages.js b/src/features/messages.js index 5535362..625f10c 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1839,7 +1839,9 @@ export function messagesFeature(ctx) { if (cur.seen.has(ev.id)) continue; cur.seen.add(ev.id); cur.sats += sats; - if (from && my.includes(from)) cur.mine = true; + // our own zap's receipt: the real thing has landed, so the optimistic + // amount the chip has been carrying since the tap steps aside + if (from && my.includes(from)) { cur.mine = true; zapPending.delete(x[1]); } changed = true; } if (changed) scheduleRepaint(); @@ -1873,15 +1875,53 @@ export function messagesFeature(ctx) { : n < 1e6 ? Math.round(n / 1000) + 'k' : (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M'; const BOLT_SVG = ''; - // The chip: a little bolt + the sats total; absent until the first receipt. + + // A tapped zap takes a few seconds to pay, and its public receipt trails + // the payment by seconds more — long enough that the tap looked ignored. + // So the chip appears the instant you tap, carrying your amount and + // pulsing while the payment is in flight; it goes solid when the payment + // lands and hands over to the real receipt when that arrives. A failure + // takes it straight back off. + const ZAP_FLIGHT_MS = 45_000; // nothing reported back: assume it's gone + const ZAP_PAID_MS = 5 * 60_000; // paid, but the receipt never showed + const zapPending = new Map(); // id -> { sats, at, paid } + const pendingLive = (p) => p && Date.now() - p.at < (p.paid ? ZAP_PAID_MS : ZAP_FLIGHT_MS); + function pendingOf(id) { + const p = zapPending.get(id); + if (p && !pendingLive(p)) { zapPending.delete(id); return null; } + return p || null; + } + function markZapPending(id, sats) { + if (!id || !sats) return; + zapPending.set(id, { sats, at: Date.now(), paid: false }); + scheduleRepaint(); + // repaint when the in-flight chip would expire, so a zap nobody ever + // reported on doesn't pulse forever + setTimeout(scheduleRepaint, ZAP_FLIGHT_MS + 200); + } + // A zap flow reporting back: paid (keep the amount, stop pulsing) or + // failed (the chip was never real — take it off). + function settleZap(id, ok, sats) { + if (!id) return; + if (!ok) { if (zapPending.delete(id)) scheduleRepaint(); return; } + const p = zapPending.get(id) || { sats: sats || 0, at: Date.now() }; + zapPending.set(id, { ...p, at: Date.now(), paid: true, sats: sats || p.sats }); + scheduleRepaint(); + } + // The chip: a little bolt + the sats total. Absent until the first receipt + // — or until you zap it yourself, which is its own kind of receipt. function zapChip(id, { onClick, cls = '' } = {}) { const z = zapTotals.get(id); - if (!z || !z.sats) return null; + const p = pendingOf(id); + const sats = (z ? z.sats : 0) + (p ? p.sats : 0); + if (!sats) return null; + const flying = p && !p.paid; return h('span', { - class: 'zap-tally' + (z.mine ? ' on' : '') + (onClick ? ' clickable' : '') + (cls ? ' ' + cls : ''), - title: t('zapTallyTitle', { n: z.sats.toLocaleString() }), + class: 'zap-tally' + ((z && z.mine) || p ? ' on' : '') + (flying ? ' flying' : '') + + (onClick ? ' clickable' : '') + (cls ? ' ' + cls : ''), + title: flying ? t('zapSending') : t('zapTallyTitle', { n: sats.toLocaleString() }), onClick: onClick ? (e) => { e.stopPropagation(); onClick(); } : undefined, - }, h('span', { style: 'display:flex', html: BOLT_SVG }), fmtSats(z.sats)); + }, h('span', { style: 'display:flex', html: BOLT_SVG }), fmtSats(sats)); } // Zap a chat message or DM: same one-tap flow as a post (ark first, // Lightning fallback, the amount remembered from the first time). @@ -2023,8 +2063,10 @@ export function messagesFeature(ctx) { const npubStr = npubOf(pk); const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; if (!def) { ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21' }; render(); return; } + markZapPending(ev.id, def); // the chip answers the tap; the flow reports back if (!hook('zapNpub', pk, npubStr, ev.id, def) && !hook('lnZapNpub', pk, npubStr, ev.id, def)) { // no instant path in this build — the classic form flow + settleZap(ev.id, false); ui.profilePk = null; ui.chatOpen = false; ui.tab = 'send'; @@ -2051,7 +2093,9 @@ export function messagesFeature(ctx) { const { pk, npub, eventId } = s; ui.zapSetup = null; render(); - if (!hook('zapNpub', pk, npub, eventId, n)) hook('lnZapNpub', pk, npub, eventId, n); + markZapPending(eventId, n); + if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) settleZap(eventId, false); + recheckZap(eventId); } }, t('zapSetupSave'))), h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.zapSetup = null; render(); } }, t('back'))); } @@ -3475,6 +3519,11 @@ export function messagesFeature(ctx) { return { id: 'messages', + // A zap flow reporting how its payment went, so the chip that appeared + // on tap can stop pulsing (paid) or disappear (failed / never fired). + // Also the way a zap sent from a form reaches the tally right away, + // without waiting on its receipt. + zapSettled(eventId, ok, sats) { settleZap(eventId, ok, sats); return true; }, // Chat lives behind a header button and takes over the whole screen — // no balance card, no tabs; each view carries its own way back. // The bare avatar node for the app header's identity menu. @@ -3662,7 +3711,7 @@ export function messagesFeature(ctx) { listsSynced = false; if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; } clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = []; - zapTotals.clear(); zapAsked.clear(); // 'mine' is per identity — refetch under the next + zapTotals.clear(); zapAsked.clear(); zapPending.clear(); // 'mine' is per identity — refetch under the next }, }; } diff --git a/src/features/zaps.js b/src/features/zaps.js index 3c0e7e1..77f8475 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -450,10 +450,14 @@ export function zapsFeature(ctx) { lnZapNpub(pk, npub, eventId, autoSat) { if (!canPay() || !wallet.nostrProfile) return false; if (autoSat && hook('arkReady')) { + // the chip on the zapped note is pulsing since the tap — tell it how + // this went, either way autoZap({ kind: 'npub', pk, eventId: eventId || null }, autoSat) - .catch((e) => toast('⚡ ' + e.message)); + .then(() => hook('zapSettled', eventId, true, autoSat)) + .catch((e) => { hook('zapSettled', eventId, false); toast('⚡ ' + e.message); }); return true; } + if (autoSat) hook('zapSettled', eventId, false); // one tap couldn't serve it — the form takes over begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk))); return true; }, diff --git a/src/i18n.js b/src/i18n.js index 5016754..428f582 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -193,6 +193,7 @@ const STR = { msgReact: 'React', msgZap: 'Zap', zapTallyTitle: '{n} sats zapped', + zapSending: 'Sending your zap…', msgReply: 'Reply', profNewPost: 'New post', profYourProfile: 'Your profile', diff --git a/src/style.css b/src/style.css index ba94c11..4028655 100644 --- a/src/style.css +++ b/src/style.css @@ -485,6 +485,15 @@ hr.divider { margin: 16px 0; } .zap-tally.chat-react { font-size: 12px; padding: 1px 7px 1px 5px; } .zap-tally.clickable { cursor: pointer; } .zap-tally.on { text-decoration: underline; text-decoration-color: color-mix(in srgb, #e6a800 60%, transparent); text-underline-offset: 2px; } +/* A zap in flight: the chip lands the moment you tap and breathes until the + payment settles — the bolt carries the pulse, the number stays readable. */ +.zap-tally.flying { animation: zap-breathe 1.15s ease-in-out infinite; } +.zap-tally.flying svg { animation: zap-flicker 1.15s ease-in-out infinite; transform-origin: center; } +@keyframes zap-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } } +@keyframes zap-flicker { 0%, 100% { transform: scale(.82); } 50% { transform: scale(1.12); } } +@media (prefers-reduced-motion: reduce) { + .zap-tally.flying, .zap-tally.flying svg { animation: none; opacity: .7; } +} .chat-compose { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); align-items: center; flex: 0 0 auto; -- cgit v1.2.3