diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/ark.js | 36 | ||||
| -rw-r--r-- | src/features/zaps.js | 54 |
2 files changed, 80 insertions, 10 deletions
diff --git a/src/features/ark.js b/src/features/ark.js index b65bce2..46ec701 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -854,7 +854,20 @@ export function arkFeature(ctx) { ui.arkLnPay = { invoice, meta: meta || null, amountSat: dec.amountSat, amount: '', feeSat: null, status: 'quote' }; ui.sendError = ''; render(); - quoteArkLnPay(invoice, meta).catch(() => { + // An auto-pay recipient (a budget the user set) pays as soon as the quote + // lands — no tap. Guarded to the budget and to a single fire; anything + // off-script (over budget, no amount, a quote error) falls back to the + // normal review with the Pay button. + const maybeAutopay = () => { + const p2 = ui.arkLnPay; + if (!p2 || p2.invoice !== invoice || p2._autopaid) return; + if (p2.status !== 'ready' || !p2.meta || !p2.meta.autopay || p2.amountSat == null) return; + const budget = p2.meta.budgetKey && ctx.lnBudget ? ctx.lnBudget(p2.meta.budgetKey) : 0; + if (!(budget > 0 && p2.amountSat <= budget)) return; + p2._autopaid = true; + doArkLnPay(); + }; + quoteArkLnPay(invoice, meta).then(maybeAutopay).catch(() => { // The quote is a nicety — a dead esplora or quote service must not // wedge the form at an eternal spinner (it did: "Getting a quote…" // above a Failed-to-fetch, Pay unreachable). Price it like the outage @@ -866,6 +879,7 @@ export function arkFeature(ctx) { p2.status = 'ready'; ui.sendError = ''; render(); + maybeAutopay(); }); return true; } @@ -943,6 +957,10 @@ export function arkFeature(ctx) { if (a.step === 'done') { ui.arkLnPaid = { amountSat: a.amountSat, meta: p.meta }; if (p.meta && p.meta.pk) noteZap('inv:' + p.invoice, p.meta.pk); + // the user opted this recipient into auto-pay: remember the budget + // so the next fixed charge from them clears without a tap + if (p.setBudget && p.meta && p.meta.budgetKey && ctx.setLnBudget) + ctx.setLnBudget(p.meta.budgetKey, a.amountSat); ui.arkLnPay = null; } else if (ui.arkLnPay) { if (tries < 2 && lnRetryable(a)) { @@ -1003,7 +1021,7 @@ export function arkFeature(ctx) { onClick: () => { ui.arkLnPaid = null; ui.send = blankSend(); render(); }, }, h('div', { class: 'check-badge' }, '⚡'), - h('h2', { style: 'margin:0' }, zap ? t('arkZapSentTitle') : t('arkLnPaidTitle')), + h('h2', { style: 'margin:0' }, zap && zap.social ? t('arkZapSentTitle') : t('arkLnPaidTitle')), zap && zap.name ? h('div', { class: 'small muted' }, zap.name) : null, h('div', { class: 'amount-neg', style: 'font-size:18px' }, '-' + fmtAmount(ui.arkLnPaid.amountSat) + u), h('div', { class: 'small muted' }, t('tapToProceed'))); @@ -1016,7 +1034,7 @@ export function arkFeature(ctx) { const total = (p.amountSat || 0) + (p.feeSat || 0); return h('div', { class: 'col', style: 'gap:12px' }, h('div', { class: 'card col', style: 'gap:10px' }, - h('h3', { style: 'margin:0' }, '⚡ ' + (zap ? t('lnZapReviewTitle') : t('lnPayTitle'))), + h('h3', { style: 'margin:0' }, '⚡ ' + (zap && zap.social ? t('lnZapReviewTitle') : t('lnPayTitle'))), h('div', { class: 'small muted' }, t('arkLnPayVia')), zap && (zap.name || zap.address) ? h('div', { class: 'small muted', style: 'word-break:break-all' }, zap.name || zap.address) : null, zap && zap.comment ? h('div', { class: 'small faint', style: 'font-style:italic;word-break:break-word' }, '“' + zap.comment + '”') : null, @@ -1039,6 +1057,18 @@ export function arkFeature(ctx) { ? [h('div', { style: 'border-top:1px solid var(--line, #ddd);margin:2px 0' }), row(t('lnPayTotal'), total, true)] : null), ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, + // Offer to skip this confirmation next time: a fixed-amount merchant + // pay with no budget yet can be trusted up to this amount. Ticking it + // stores the budget on a successful pay (see settle); after that the + // same recipient auto-pays without this screen. + (zap && zap.budgetKey && zap.fixedSat && !zap.autopay + && p.status === 'ready' + && !(ctx.lnBudget && ctx.lnBudget(zap.budgetKey) > 0)) + ? h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' }, + h('input', { type: 'checkbox', checked: !!p.setBudget, style: 'flex:0 0 auto;margin:0', + onChange: (e) => { p.setBudget = e.target.checked; } }), + h('span', { class: 'small muted' }, t('lnAutopayOffer', { name: zap.name || zap.address || '', n: fmtAmount(zap.fixedSat) + ' ' + unitLabel() }))) + : null, p.status === 'paying' ? h('div', { class: 'card row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), diff --git a/src/features/zaps.js b/src/features/zaps.js index b1af6a9..fc11f80 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -61,12 +61,24 @@ export function zapsFeature(ctx) { return true; } + // A payment is a nostr ZAP only when it targets a person or a note (an + // npub, or a note-zap carrying an eventId). Paying a lightning address, + // an LNURL, or a merchant is a PAYMENT — even if the endpoint happens to + // support nostr zaps. Computed at begin() because resolve() rewrites the + // target kind (npub → its lnaddr) but keeps the eventId. + const isSocial = (target) => target.kind === 'npub' || !!target.eventId; + + // A stable id for a fixed-amount recipient's auto-pay budget: their + // lightning address if we have one, else the LNURL bech32, else their pk. + const budgetKeyOf = (z) => + z.address || z.target.lnurlBech32 || (z.target.pk ? 'pk:' + z.target.pk : null); + // Kick off resolution of a parsed target. `display` is what we show as the // recipient until a profile name arrives. function begin(target, display) { ui.arkZap = null; ui.arkZapped = null; // in case we were handed off from ark ui.sendError = ''; - const z = (ui.zap = { status: 'resolving', target, name: display, address: target.address || null, amount: '', comment: '' }); + const z = (ui.zap = { status: 'resolving', target, name: display, address: target.address || null, amount: '', comment: '', social: isSocial(target) }); render(); resolve(z).catch((e) => { if (ui.zap === z) { z.status = 'error'; z.error = e.message; render(); } }); } @@ -154,6 +166,22 @@ export function zapsFeature(ctx) { } z.params = params; z.status = 'ready'; + // A fixed-amount payment (not a social zap) has nothing to type, so the + // amount screen is a pointless confirm — go straight to the quote. And + // if this recipient carries an auto-pay budget that covers it, pay + // outright (confirm() passes autopay through to the review, which fetches + // the quote and pays without a tap). Over budget, or funds don't cover + // → show the screen so the amount/board handoff is still available. + if (!z.social && params.fixedSat) { + const ours = hook('lnSpendableSat'); + const covers = ours == null || params.fixedSat <= ours; + if (covers) { + const key = budgetKeyOf(z); + z.autopay = !!(key && ctx.lnBudget && params.fixedSat <= (ctx.lnBudget(key) || 0)); + confirm(); + return; + } + } render(); } @@ -240,7 +268,16 @@ export function zapsFeature(ctx) { }); } const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment }); - const meta = { name: z.name, address: z.address, pk: z.target.pk || null, comment: canZap ? z.comment : '', isZap: !!zapRequest }; + const meta = { + name: z.name, address: z.address, pk: z.target.pk || null, + comment: canZap ? z.comment : '', isZap: !!zapRequest, social: z.social, + // a fixed-amount payment can carry (or offer to set) an auto-pay + // budget for this recipient — the review honors autopay and, when + // offered, remembers the budget on confirm + budgetKey: !z.social && p.fixedSat ? budgetKeyOf(z) : null, + fixedSat: p.fixedSat || null, + autopay: !!z.autopay, + }; ui.zap = null; // Hand the bolt11 to the swaps feature's pay flow (review → confirm). hook('startLnPay', invoice, meta); @@ -253,6 +290,9 @@ export function zapsFeature(ctx) { function zapView() { const z = ui.zap; if (!z) return null; + // "Zap" only for a social zap (a person / a note); a plain LNURL or + // merchant payment is a "Pay". + const heading = '⚡ ' + (z.social ? t('zapTitle') : t('lnPayTitle')); const back = () => { ui.zap = null; ui.sendError = ''; ui.send = blankSend(); render(); }; // Resolution is a background step, not a screen: the send form stays put // and shows one inline line (resolvingNote below) until we can render @@ -260,7 +300,7 @@ export function zapsFeature(ctx) { if (z.status === 'resolving') { // twin of ark's skeleton: identical shape, so the handoff is invisible return h('div', { class: 'card col', style: 'gap:12px' }, - h('h3', {}, '⚡ ' + t('zapTitle')), + h('h3', {}, heading), (z.target && z.target.pk && hook('profileChip', z.target.pk, 'lg')) || h('div', { class: 'small muted break' }, z.name || z.address || ''), h('div', { class: 'row gap6', style: 'align-items:center;padding:6px 0' }, @@ -275,7 +315,7 @@ export function zapsFeature(ctx) { } if (z.status === 'error') { return h('div', { class: 'card col', style: 'gap:12px' }, - h('h3', {}, '⚡ ' + t('zapTitle')), + h('h3', {}, heading), (z.target && z.target.pk && hook('profileChip', z.target.pk, 'lg')) || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.name || ''), h('div', { class: 'notice err' }, z.error || t('lnZapFailed')), h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); @@ -298,7 +338,7 @@ export function zapsFeature(ctx) { const boardable = !!hook('arkReady') && ours != null; const needsBoard = boardable && (broke || (typedSats > 0 && typedSats > ours && typedSats <= theirMaxSat)); return h('div', { class: 'card col', style: 'gap:12px' }, - h('h3', {}, '⚡ ' + t('zapTitle')), + h('h3', {}, heading), (z.target && z.target.pk && hook('profileChip', z.target.pk, 'lg')) || h('div', { class: 'small muted', style: 'word-break:break-all' }, z.name || z.address || ''), h('div', { class: 'col gap6' }, h('div', { class: 'input-group' }, @@ -323,13 +363,13 @@ export function zapsFeature(ctx) { maxlength: p.allowsNostr ? '280' : String(p.commentAllowed || 280), onInput: (e) => { z.comment = e.target.value; } }) : null, - h('div', { class: 'small faint' }, p.allowsNostr ? t('lnZapHintNostr') : t('lnZapHintPlain'))), + z.social ? h('div', { class: 'small faint' }, p.allowsNostr ? t('lnZapHintNostr') : t('lnZapHintPlain')) : null), z.error ? h('div', { class: 'notice err' }, z.error) : null, h('div', { class: 'row gap6' }, h('button', { class: 'btn-ghost', onClick: back }, t('back')), needsBoard ? h('button', { class: 'btn-primary grow', onClick: () => hook('arkOfferBoard', typedSats || min) }, t('zapBoardBtn')) - : h('button', { class: 'btn-primary grow', onClick: confirm, disabled: broke }, t('arkZapBtn')))); + : h('button', { class: 'btn-primary grow', onClick: confirm, disabled: broke }, z.social ? t('arkZapBtn') : t('lnPayConfirm')))); } return { |
