aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/ark.js21
-rw-r--r--src/features/messages.js65
-rw-r--r--src/features/zaps.js6
-rw-r--r--src/i18n.js1
-rw-r--r--src/style.css9
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;