diff options
| -rw-r--r-- | src/features/ark.js | 21 | ||||
| -rw-r--r-- | src/features/messages.js | 65 | ||||
| -rw-r--r-- | src/features/zaps.js | 6 | ||||
| -rw-r--r-- | src/i18n.js | 1 | ||||
| -rw-r--r-- | src/style.css | 9 |
5 files changed, 88 insertions, 14 deletions
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 = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none" style="display:block"><path d="M13 2L4 14h7l-1 8 9-12h-7l1-8z"/></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; |
