aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ark/manager.js6
-rw-r--r--src/features/ark.js19
-rw-r--r--src/features/messages.js170
-rw-r--r--src/i18n.js4
-rw-r--r--src/style.css2
5 files changed, 148 insertions, 53 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index c3a8b58..1381095 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -833,8 +833,12 @@ export class ArkManager {
changeIndex: changeSat > 0 ? this.state.nextKeyIndex++ : null,
};
});
+ // Two zaps tapped in quick succession can resolve in the same
+ // millisecond; each needs its own row.
+ let id = `send-${Date.now()}`;
+ while (this.state.actions.some((a) => a.id === id)) id += '-';
const action = {
- id: `send-${Date.now()}`, type: 'send', step: 'created',
+ id, type: 'send', step: 'created',
parts, amountSat, destAddress: addrString,
destPubkey: dest.userPubkey, destBlindedId: mailboxDelivery.data,
};
diff --git a/src/features/ark.js b/src/features/ark.js
index dbc97f6..5327a23 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1554,11 +1554,18 @@ export function arkFeature(ctx) {
// 3. an on-chain address from the BIP-353 record
const NAMES_REGISTRAR = 'https://names.coinos.io';
function startNpubPay(pk, npub, eventId = null, autoSat = 0) {
- const z = (ui.arkZap = { npub, pk, eventId, amount: '', comment: '', status: 'lookup', autoSat });
- ui.zap = null; // a stale Lightning-zap card must not resurface behind ours
- ui.sendError = '';
- render();
- const live = () => ui.arkZap === z;
+ const z = { npub, pk, eventId, amount: '', comment: '', status: 'lookup', autoSat };
+ // A one-tap post zap has no card: it resolves and pays off-screen, so a
+ // second tap — the same post again, or another — never evicts the one
+ // already in the air. Everything else takes the send screen's zap slot.
+ const instant = !!(autoSat && eventId);
+ if (!instant) {
+ ui.arkZap = z;
+ ui.zap = null; // a stale Lightning-zap card must not resurface behind ours
+ ui.sendError = '';
+ render();
+ }
+ const live = () => instant || 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
@@ -1627,7 +1634,7 @@ export function arkFeature(ctx) {
if (profile && (profile.lud16 || profile.lud06)) {
await connectArk().catch(() => {});
if (!live()) return;
- if (ctx.hook('canLnZap')) { ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; }
+ if (ctx.hook('canLnZap')) { if (ui.arkZap === z) ui.arkZap = null; ctx.hook('lnZapNpub', pk, npub, z.eventId, z.autoSat); return; }
}
// 3. on-chain fallback
if (onchain) {
diff --git a/src/features/messages.js b/src/features/messages.js
index 2d48cb6..24542b9 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -2983,7 +2983,7 @@ export function messagesFeature(ctx) {
}
// 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; voidPending(x[1], ev.created_at * 1000); }
+ if (from && my.includes(from)) { cur.mine = true; voidPending(x[1], ev.created_at * 1000, sats); }
changed = true;
}
if (changed) { saveZapTotals(); scheduleRepaint(); }
@@ -3079,27 +3079,46 @@ export function messagesFeature(ctx) {
// 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
- // id -> { sats, at, state }, where state is:
+ // id -> [{ sats, at, state }], oldest first: one entry per zap of ours on
+ // that id, so tapping again while the first is still in the air stacks a
+ // second zap on top instead of being refused. state is:
// flying — tapped, payment in the air: counted on top, chip pulses
// paid — the flow reported success: counted on top, chip solid
// void — one of OUR receipts has been counted into the real total for
- // this id, so the optimistic amount must not be added again.
+ // this zap, so its optimistic amount must not be added again.
// Kept rather than deleted, because the receipt usually beats
// the flow's own report: an ark zap publishes that receipt
// itself, and it returns over the live subscription before the
// send call resolves. Deleting would let the later settle
// resurrect the amount — which is how a 21-sat zap displayed 42.
+ // Reports and receipts name the id, never the tap, so each is matched to
+ // the oldest entry it can describe (same amount first). Two default-amount
+ // zaps are told apart by order alone, which is all the chip needs: it shows
+ // the sum, and pulses while any is in the air.
const zapPending = new Map();
const pendTtl = (p) => (p.state === 'flying' ? ZAP_FLIGHT_MS : ZAP_PAID_MS);
+ // Everything still alive for this id, and what the chip makes of it.
function pendingOf(id) {
- const p = zapPending.get(id);
- if (!p) return null;
- if (Date.now() - p.at > pendTtl(p)) { zapPending.delete(id); return null; }
- return p;
+ const all = zapPending.get(id);
+ if (!all) return null;
+ const now = Date.now();
+ const list = all.filter((p) => now - p.at <= pendTtl(p));
+ if (!list.length) { zapPending.delete(id); return null; }
+ zapPending.set(id, list); // the array handed back is the one stored: splices land
+ let sats = 0, flying = false;
+ for (const p of list) if (p.state !== 'void') { sats += p.sats; flying = flying || p.state === 'flying'; }
+ return { sats, flying, list };
}
+ const pickPending = (list, sats, pred) =>
+ (sats && list.find((p) => pred(p) && p.sats === sats)) || list.find(pred) || null;
+ const dropPending = (id, list, p) => {
+ list.splice(list.indexOf(p), 1);
+ if (!list.length) zapPending.delete(id);
+ };
function markZapPending(id, sats, origin) {
if (!id || !sats) return;
- zapPending.set(id, { sats, at: Date.now(), state: 'flying' });
+ const list = pendingOf(id)?.list || [];
+ zapPending.set(id, [...list, { sats, at: Date.now(), state: 'flying' }]);
animateZap(sats, origin, id);
render();
// repaint when the in-flight chip would expire, so a zap nobody ever
@@ -3110,42 +3129,75 @@ export function messagesFeature(ctx) {
// failed (the chip was never real — take it off).
function settleZap(id, ok, sats) {
if (!id) return;
- const p = pendingOf(id);
+ const list = pendingOf(id)?.list || [];
if (!ok) {
- if (p && p.state !== 'void') { zapPending.delete(id); scheduleRepaint(); }
+ const p = pickPending(list, sats, (x) => x.state === 'flying') || pickPending(list, sats, (x) => x.state === 'paid');
+ if (p) { dropPending(id, list, p); scheduleRepaint(); }
return;
}
- if (p && p.state === 'void') return; // the receipt is already carrying it
- zapPending.set(id, { sats: sats || (p && p.sats) || 0, at: Date.now(), state: 'paid' });
+ const p = pickPending(list, sats, (x) => x.state === 'flying');
+ if (p) { p.state = 'paid'; p.at = Date.now(); scheduleRepaint(); return; }
+ // nothing in the air: the receipt already beat this report (its marker
+ // stays, so a repeated confirmation can't count it either), or a form
+ // zap paid without a tap — count that one until its receipt lands
+ if (pickPending(list, sats, (x) => x.state === 'void')) return;
+ zapPending.set(id, [...list, { sats: sats || 0, at: Date.now(), state: 'paid' }]);
scheduleRepaint();
}
// One of our own receipts has been counted for this id: the real total
- // speaks for the zap now, so the optimistic amount stands down.
- function voidPending(id, evMs) {
- const p = zapPending.get(id);
+ // speaks for that zap now, so its optimistic amount stands down.
+ function voidPending(id, evMs, sats) {
+ const list = pendingOf(id)?.list || [];
// ...unless it's the receipt of an OLDER zap of ours arriving (the first
// paint fetches every receipt a message has). That one is already in the
- // total and says nothing about the zap currently in the air.
- if (p && p.state !== 'void' && evMs && evMs < p.at - 120_000) return;
- zapPending.set(id, { sats: 0, at: Date.now(), state: 'void' });
+ // total and says nothing about the zaps currently in the air.
+ const fresh = (p) => p.state !== 'void' && !(evMs && evMs < p.at - 120_000);
+ const p = pickPending(list, sats, fresh);
+ if (p) { p.state = 'void'; p.at = Date.now(); return; }
+ if (evMs && evMs < Date.now() - 120_000) return; // old: no report is coming for it
+ zapPending.set(id, [...list, { sats: sats || 0, at: Date.now(), state: 'void' }]);
}
// 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 = '' } = {}) {
+ function zapChip(id, { onClick, onHold, cls = '' } = {}) {
if (onClick) warmZapSound();
const z = zapTotals.get(id) || zapSeeds().get(id);
const p = pendingOf(id);
- const optimistic = p && p.state !== 'void' ? p.sats : 0;
+ const optimistic = p ? p.sats : 0;
const sats = (z ? z.sats : 0) + optimistic;
if (!sats) return null;
- const flying = !!p && p.state === 'flying';
+ const flying = !!p && p.flying;
return h('span', {
class: 'zap-tally' + ((z && z.mine) || optimistic ? ' on' : '') + (flying ? ' flying' : '')
+ (onClick ? ' clickable' : '') + (cls ? ' ' + cls : ''),
title: flying ? t('zapSending') : t('zapTallyTitle', { n: sats.toLocaleString() }),
- onClick: onClick ? (e) => { e.stopPropagation(); onClick(); } : undefined,
+ ...(onClick ? holdable(onHold, (e) => { e.stopPropagation(); onClick(); }) : {}),
}, h('span', { style: 'display:flex', html: BOLT_SVG }), fmtSats(sats));
}
+ // Hold the ⚡ to change the one-tap amount instead of paying it. The
+ // press's state lives on the element, not in this closure: a background
+ // repaint swaps handlers mid-press, and the lift must still find the timer
+ // the press set, or a finger already gone would count as a hold.
+ const HOLD_MS = 500;
+ function holdable(onHold, onTap) {
+ const clear = (e) => { const el = e.currentTarget; if (el._hold) { clearTimeout(el._hold); el._hold = 0; } };
+ if (!onHold) return { onClick: onTap };
+ return {
+ onPointerdown: (e) => {
+ const el = e.currentTarget;
+ clear(e); el._held = false;
+ if (e.button) return; // right button has its own menu
+ el._hold = setTimeout(() => {
+ el._hold = 0; el._held = true;
+ try { navigator.vibrate?.(12); } catch {}
+ onHold(el);
+ }, HOLD_MS);
+ },
+ onPointerup: clear, onPointercancel: clear, onPointerleave: clear,
+ onContextmenu: (e) => { e.preventDefault(); },
+ onClick: (e) => { const el = e.currentTarget; if (el._held) { el._held = false; e.stopPropagation(); return; } onTap(e); },
+ };
+ }
// Zap a chat message or DM: same one-tap flow as a post (ark first,
// Lightning fallback, the amount remembered from the first time).
function zapMessage(pk, id) {
@@ -4282,7 +4334,6 @@ export function messagesFeature(ctx) {
// round-trip, and one bar that behaves the same everywhere beats a special case.
const canZapPk = (pk) => !!pk && !!(hook('arkReady') || hook('canLnZap'));
function zapNote(pk, ev, origin) {
- if (pendingOf(ev.id)?.state === 'flying') return;
const npubStr = npubOf(pk);
const def = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;
if (!def) { ui.zapSetup = { pk, npub: npubStr, eventId: ev.id, amount: '21', origin }; render(); return; }
@@ -4293,31 +4344,55 @@ export function messagesFeature(ctx) {
}
}
+ // Held ⚡: the same screen, opened to change the amount — saving alone is
+ // the main action, zapping this note with the new amount the second.
+ function openZapSettings(pk, id, origin) {
+ const cur = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0;
+ ui.zapSetup = { pk, npub: npubOf(pk), eventId: id, amount: String(cur || 21), origin, edit: true };
+ render();
+ }
+
// First ⚡ tap ever: pick the amount one time, then every zap is one tap.
function zapSetupScreen() {
const s = ui.zapSetup;
+ const saved = () => {
+ const n = parseInt(s.amount, 10);
+ if (!n || n <= 0) { toast(t('enterValidAmtForN', { n: 1 })); return 0; }
+ ctx.setZapDefaultSat(n);
+ return n;
+ };
+ const saveAndZap = () => {
+ const n = saved();
+ if (!n) return;
+ const { pk, npub, eventId } = s;
+ ui.zapSetup = null;
+ render();
+ markZapPending(eventId, n, s.origin);
+ if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) {
+ settleZap(eventId, false);
+ toast('⚡ ' + t('lnZapFailed'), 4000);
+ }
+ recheckZap(eventId);
+ };
+ const saveOnly = () => {
+ const n = saved();
+ if (!n) return;
+ ui.zapSetup = null;
+ toast('⚡ ' + t('zapAmountSaved', { n: n.toLocaleString() }));
+ render();
+ };
return h('div', { class: 'col', style: 'gap:16px' },
ctx.brandHeader(false),
h('div', { class: 'card col', style: 'gap:10px' },
h('h3', { style: 'margin:0' }, '⚡ ' + t('zapSetupTitle')),
- h('div', { class: 'small muted' }, t('zapSetupDesc')),
+ h('div', { class: 'small muted' }, t(s.edit ? 'zapSettingsDesc' : 'zapSetupDesc')),
h('div', { class: 'input-group' },
h('input', { type: 'number', min: '1', value: s.amount, onInput: (e) => { s.amount = e.target.value; } }),
h('span', { class: 'small muted', style: 'align-self:center;padding:0 8px' }, 'sats')),
- h('button', { class: 'btn-primary btn-block', onClick: () => {
- const n = parseInt(s.amount, 10);
- if (!n || n <= 0) { toast(t('enterValidAmtForN', { n: 1 })); return; }
- ctx.setZapDefaultSat(n);
- const { pk, npub, eventId } = s;
- ui.zapSetup = null;
- render();
- markZapPending(eventId, n, s.origin);
- if (!hook('zapNpub', pk, npub, eventId, n) && !hook('lnZapNpub', pk, npub, eventId, n)) {
- settleZap(eventId, false);
- toast('⚡ ' + t('lnZapFailed'), 4000);
- }
- recheckZap(eventId);
- } }, t('zapSetupSave'))),
+ s.edit
+ ? [h('button', { class: 'btn-primary btn-block', onClick: saveOnly }, t('save')),
+ h('button', { class: 'btn-ghost btn-block', onClick: saveAndZap }, t('zapSetupSave'))]
+ : h('button', { class: 'btn-primary btn-block', onClick: saveAndZap }, t('zapSetupSave'))),
h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.zapSetup = null; render(); } }, t('back')));
}
@@ -4552,9 +4627,10 @@ export function messagesFeature(ctx) {
const rm = reacts.get(ev.id);
const likeN = rm ? [...rm.values()].reduce((n, { who }) => n + who.size, 0) : 0;
const boostN = (boosts.get(ev.id) || new Set()).size;
- const btn = (icon, label, count, on, onClick, cls = '') => h('button', {
+ const btn = (icon, label, count, on, onClick, cls = '', extra = null) => h('button', {
class: 'note-act' + (on ? ' on' : '') + (cls ? ' ' + cls : ''), title: label, 'aria-label': label,
'aria-disabled': onClick ? undefined : 'true',
+ ...(extra || {}),
onClick: (e) => { e.stopPropagation(); if (onClick) onClick(e); },
},
typeof icon === 'string' && icon.startsWith('<svg')
@@ -4565,9 +4641,12 @@ export function messagesFeature(ctx) {
// do on theirs — amber once anyone has zapped, breathing while ours flies
const z = zapTotals.get(ev.id) || zapSeeds().get(ev.id);
const zp = pendingOf(ev.id);
- const optimistic = zp && zp.state !== 'void' ? zp.sats : 0;
+ const optimistic = zp ? zp.sats : 0;
const zapSats = (z ? z.sats : 0) + optimistic;
- const zapFlying = !!zp && zp.state === 'flying';
+ const zapFlying = !!zp && zp.flying;
+ // tap pays the default amount; hold opens the screen that sets it
+ const zapHold = canZap ? holdable((el) => openZapSettings(pk, ev.id, el.getBoundingClientRect()),
+ (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect()); recheckZap(ev.id); }) : null;
const zapMine = !!((z && z.mine) || optimistic);
const zapLabel = zapFlying ? t('zapSending') : zapSats ? t('zapTallyTitle', { n: zapSats.toLocaleString() }) : t('zapTitle');
return h('div', { class: 'row note-acts' },
@@ -4580,8 +4659,9 @@ export function messagesFeature(ctx) {
// always in the bar — the same row of buttons on every post, so nothing
// shifts when a wallet comes online; without one it is just the number
btn(I_ZAP, zapLabel, zapSats ? fmtSats(zapSats) : 0, zapMine,
- canZap ? (e) => { zapNote(pk, ev, e.currentTarget.getBoundingClientRect()); recheckZap(ev.id); } : null,
- 'note-zap' + (zapSats ? ' zapped' : '') + (zapFlying ? ' flying' : '')),
+ zapHold && zapHold.onClick,
+ 'note-zap' + (zapSats ? ' zapped' : '') + (zapFlying ? ' flying' : ''),
+ zapHold && { ...zapHold, onClick: undefined }),
// who did all that: a small chevron, always in place — dimmed until
// there is someone to show, so the first reaction doesn't reflow the bar
(() => {
@@ -6177,7 +6257,7 @@ export function messagesFeature(ctx) {
title: t('msgReact'),
onClick: (e) => { e.stopPropagation(); sendReaction(room, chId, m, emoji); },
}, reactNode(emoji), n > 1 ? ' ' + n : '')))
- : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null }))))
+ : null)(zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.author) ? () => zapMessage(m.author, m.rumor.id) : null, onHold: () => openZapSettings(m.author, m.rumor.id) }))))
);
});
}
@@ -6382,7 +6462,7 @@ export function messagesFeature(ctx) {
watchZaps(msgs.slice(-150).map((m) => m.rumor.id));
const dmChips = (m) => {
const reacts = dmReacts.get(m.rumor.id);
- const zc = zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.rumor.pubkey) ? () => zapMessage(m.rumor.pubkey, m.rumor.id) : null });
+ const zc = zapChip(m.rumor.id, { cls: 'chat-react', onClick: canZapPk(m.rumor.pubkey) ? () => zapMessage(m.rumor.pubkey, m.rumor.id) : null, onHold: () => openZapSettings(m.rumor.pubkey, m.rumor.id) });
if ((!reacts || !reacts.size) && !zc) return null;
const myReact = reacts && my.map((pk) => reacts.get(pk)).find(Boolean);
const counts = new Map();
diff --git a/src/i18n.js b/src/i18n.js
index 0dd7513..84d1a5a 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -960,8 +960,10 @@ const STR = {
addWalletGo: 'Continue',
zapSentShort: 'Zapped {n}',
zapSetupTitle: 'Set your zap amount',
- zapSetupDesc: 'One tap on ⚡ sends this amount instantly — no form, no confirmation. You can change it any time in Settings → Payments.',
+ zapSetupDesc: 'One tap on ⚡ sends this amount instantly — no form, no confirmation. Change it any time by holding ⚡, or in Settings → Payments.',
zapSetupSave: 'Save and zap',
+ zapSettingsDesc: 'This is what one tap on ⚡ sends — no form, no confirmation. Tap again while one is still on its way to send another.',
+ zapAmountSaved: 'One tap now zaps {n} sats',
zapDefaultLabel: 'One-tap zap amount',
zapDefaultHint: 'What a single tap of the ⚡ button sends.',
threadNoReplies: 'No replies yet.',
diff --git a/src/style.css b/src/style.css
index 3629dfc..4e93009 100644
--- a/src/style.css
+++ b/src/style.css
@@ -601,6 +601,8 @@ hr.divider { margin: 16px 0; }
/* the zap button carries the sats tally: amber once anyone has zapped, the
number in amber too; breathing while our own zap is in the air */
.note-act.note-zap.zapped, .note-act.note-zap.zapped .note-act-n { color: #e6a800; }
+/* holdable: no text selection or callout while the finger rests on it */
+.note-act.note-zap, .zap-tally.clickable { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.note-act.note-zap[aria-disabled] { cursor: default; }
.note-act.note-zap[aria-disabled]:hover { background: none; color: var(--faint); }
.note-act.note-zap.zapped[aria-disabled]:hover { color: #e6a800; }