aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/gifts.js72
-rw-r--r--src/features/messages.js89
-rw-r--r--src/features/withdraw-links.js195
3 files changed, 302 insertions, 54 deletions
diff --git a/src/features/gifts.js b/src/features/gifts.js
index 42e794d..c6ccbd9 100644
--- a/src/features/gifts.js
+++ b/src/features/gifts.js
@@ -8,6 +8,7 @@ import { decryptWithCode, npubOf } from '../nostr.js';
import { installGiftWallet, previewGift, giftOutpoints, giftClaimTxs, isRetiredGift, giftMinimum, lockGift, previewLockedGift } from './gifts-wallet.js';
import { t } from '../i18n.js';
import { qrSvg } from '../qr.js';
+import { withdrawLinks } from './withdraw-links.js';
import { fmtBtc, timeAgo, shortAddr } from '../format.js';
import { getEventHash, verifyEvent } from 'nostr-tools/pure';
import { sha256 } from '@noble/hashes/sha256';
@@ -26,6 +27,7 @@ export function giftsFeature(ctx) {
// every call degrades to null in an on-chain-only build.
const hook = ctx.hook || (() => null);
const arkGiftOf = (code) => hook('arkGiftDecode', code);
+ const wl = withdrawLinks(ctx); // prepaid LNURL-withdraw links, the gift link's sibling on Send
// ================================================================ GIFT / CLAIM
// Read a gift link, returning the code (and scrubbing it from the URL so the
@@ -288,51 +290,47 @@ export function giftsFeature(ctx) {
h('h2', { style: 'margin:0' }, claimed ? t('claimedTitle') : t('newWalletTitle')),
h('p', { class: 'muted', style: 'margin:0' }, claimed ? t('claimedBody') : t('newWalletBody'))
),
- h('div', { class: 'card col' },
- h('h3', {}, t('recoveryPhrase')),
- h('div', { class: 'warn-box' }, t('writeDownWarn')),
- h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) },
- words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w)))
- ),
- h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))),
- h('button', { class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('verifyBackup')),
- h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification'))
- )
+ // a heads-up before the words, then the words, then one word to
+ // check (Adam, 2026-11-07) — the same three steps as a new wallet
+ !ui.claimShown
+ ? h('div', { class: 'card col' },
+ h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')),
+ h('p', { style: 'margin:0' }, t('seedIntroBody', { n: words.length })),
+ h('p', { class: 'muted', style: 'margin:0' }, t('seedIntroPrivate')),
+ h('button', { id: 'claim-seed-show', class: 'btn-primary btn-block', onClick: () => { ui.claimShown = true; render(); } }, t('seedIntroShow')))
+ : h('div', { class: 'card col' },
+ h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')),
+ h('div', { class: 'words', style: '--rows-3:' + Math.ceil(words.length / 3) + ';--rows-2:' + Math.ceil(words.length / 2) },
+ words.map((w, i) => h('div', { class: 'w' }, h('span', { class: 'n' }, i + 1), h('span', { class: 't' }, w)))
+ ),
+ h('div', { class: 'row gap6' }, copyBtn(wallet.mnemonic, t('copyPhrase'))),
+ h('button', { id: 'claim-seed-next', class: 'btn-primary btn-block', onClick: () => { ui.confirm = pickConfirm(words); ui.claimError = ''; ui.claimStep = 'verify'; render(); } }, t('next')))
);
}
if (ui.claimStep === 'verify') {
- // Same word-confirmation as new-wallet creation: prove the recipient wrote
- // the phrase down before sending them off into their freshly funded wallet.
const words = wallet.mnemonic.split(' ');
+ const q = ui.confirm;
+ if (!q || !q.options) { ui.claimStep = 'backup'; ui.claimShown = true; return claimScreen(); }
return h(
'div',
{ class: 'col', style: 'gap:16px' },
brandHeader(false),
h('div', { class: 'card col' },
h('h3', { style: 'margin-top:0' }, t('recoveryPhrase')),
- h('p', { class: 'muted', style: 'margin:0' }, t('confirmBackupIntro')),
- ...ui.confirm.map((c, i) =>
- h('label', { class: 'field' },
- h('span', { class: 'lab' }, t('wordN', { n: c.index + 1 })),
- h('input', {
- type: 'text', class: 'mono-input', autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
- value: c.value, onInput: (e) => (ui.confirm[i].value = e.target.value.trim()),
- })
- )
- ),
- ui.claimError && h('div', { class: 'notice err' }, ui.claimError),
- h('div', { class: 'row gap6' },
- h('button', { class: 'btn-ghost', onClick: () => { ui.claimStep = 'backup'; ui.claimError = ''; render(); } }, t('back')),
+ h('p', { style: 'margin:0' }, t('quizPrompt', { n: q.index + 1 })),
+ h('div', { class: 'quiz-opts' }, q.options.map((w) =>
h('button', {
- class: 'btn-primary grow',
+ type: 'button',
+ class: 'quiz-opt' + (q.wrong.includes(w) ? ' wrong' : ''),
onClick: () => {
- const ok = ui.confirm.every((c) => c.value.toLowerCase() === words[c.index]);
- if (!ok) { ui.claimError = t('wordsMismatch'); render(); return; }
- finishClaim();
+ if (w === words[q.index]) { ui.claimError = ''; finishClaim(); return; }
+ if (!q.wrong.includes(w)) q.wrong.push(w);
+ ui.claimError = t('quizWrong'); render();
},
- }, t('openWallet'))
- ),
- h('button', { class: 'btn-block', onClick: finishClaim }, t('skipVerification'))
+ }, w))),
+ ui.claimError && h('div', { class: 'notice err' }, ui.claimError),
+ h('button', { id: 'claim-seed-skip', class: 'btn-ghost btn-block', onClick: () => { ui.claimError = ''; finishClaim(); } }, t('skip')),
+ h('button', { class: 'linklike small', style: 'align-self:center', onClick: () => { ui.claimStep = 'backup'; ui.claimShown = true; ui.claimError = ''; render(); } }, t('quizBack')),
)
);
}
@@ -366,7 +364,7 @@ export function giftsFeature(ctx) {
// view the claim on a block explorer, or head back to the home screen.
h('button', { class: 'btn-primary btn-block', onClick: () => {
ui.claimTaken = null; ui.claimedAmount = 0;
- if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; commitAccount(); }
+ if (activeAccount() && activeAccount().provisional) { ui.claimStep = 'backup'; ui.claimShown = false; commitAccount(); }
else { ui.screen = 'wallet'; ui.claimStep = null; landAccount(); }
render();
} }, activeAccount() && activeAccount().provisional ? t('createWalletAnyway') : t('goToWallet')),
@@ -488,7 +486,7 @@ export function giftsFeature(ctx) {
commitAccount(); // claimed — keep a fresh gift wallet (no-op for an existing one)
if (ui.claimFrom) hook('followGiftSender', ui.claimFrom);
if (wasProvisional) {
- ui.claimStep = 'backup'; // fresh wallet — show the new seed to back up
+ ui.claimStep = 'backup'; ui.claimShown = false; // fresh wallet — show the new seed to back up
} else {
ui.screen = 'wallet'; // existing wallet — straight into it, no new seed to back up
ui.claimStep = null;
@@ -1089,9 +1087,11 @@ export function giftsFeature(ctx) {
return { blob, claimCode, url: `${location.origin}/lg/${blob}` };
},
receiveTakeover() { return claimCelebration(); },
- sendView() { return ui.giftMode ? giftView() : null; },
+ sendView() { return ui.giftMode === 'lnurl' ? wl.view() : ui.giftMode ? giftView() : null; },
sendFormExtras() {
- return h('button', { class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink'));
+ return h('div', { class: 'row gap6 wrap', style: 'justify-content:center;column-gap:18px' },
+ h('button', { class: 'linklike small', style: 'margin-top:2px', onClick: () => { ui.giftMode = true; ui.sendError = ''; render(); } }, '🎁 ' + t('giftLink')),
+ wl.entry());
},
balanceLines() {
const locked = wallet.giftLockedValue();
diff --git a/src/features/messages.js b/src/features/messages.js
index 90bfaaf..6c4f5f8 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -760,14 +760,38 @@ export function messagesFeature(ctx) {
let allUnsubs = [];
let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt
+ // Is the page under the reader's finger, or still coasting from a flick?
+ // Background work (a repaint for a face that arrived, writing the profile
+ // cache) waits for it to settle: on Adam's Pixel a flick through the feed
+ // spent 1.7 s of 9 on the main thread doing exactly that, and taps queued
+ // behind it (2026-11-07, "flick to scroll … not getting up to speed …
+ // clicking around feels sluggish"). Never longer than `most` ms, though:
+ // a slow, steady read must still see what arrived.
+ let pageMovedAt = 0, fingerDown = false;
+ if (typeof window !== 'undefined') {
+ window.addEventListener('scroll', () => { pageMovedAt = Date.now(); }, { passive: true, capture: true });
+ window.addEventListener('touchstart', () => { fingerDown = true; }, { passive: true, capture: true });
+ for (const n of ['touchend', 'touchcancel']) window.addEventListener(n, (e) => { if (!e.touches || !e.touches.length) fingerDown = false; }, { passive: true, capture: true });
+ }
+ const pageMoving = () => fingerDown || Date.now() - pageMovedAt < 200;
+ const whenSettled = (fn, most = 1500, step = 150) => {
+ const until = Date.now() + most;
+ const go = () => { if (pageMoving() && Date.now() < until) { setTimeout(go, step); return; } fn(); };
+ go();
+ };
+
let repaintTimer = null;
const scheduleRepaint = () => {
if (repaintTimer) return;
+ const since = Date.now();
const run = () => {
// not under a pager in motion: the page beneath is out of sight, and
// its repaint (40–55 ms on Adam's phone) dropped frames from flights
// through the pictures (2026-10-17) — it waits for the landing
if (pagerMoving()) { repaintTimer = setTimeout(run, 120); return; }
+ // nor mid-flick on the page itself (up to 1.5 s): a whole-page morph
+ // in the middle of a fling is a dropped frame and a late tap
+ if (pageMoving() && Date.now() - since < 1500) { repaintTimer = setTimeout(run, 150); return; }
repaintTimer = null;
// a visitor's public page (a profile, a thread, a feed) repaints too
if (ui.screen === 'wallet' || ui.pubProf) renderThreadStable();
@@ -826,11 +850,14 @@ export function messagesFeature(ctx) {
// memory and is written back once per burst: a batch of thirty profiles
// landing during a scroll used to parse and re-serialize the whole blob
// thirty times over, on the main thread, mid-scroll.
- let profBlob = null, profFlush = null;
+ let profBlob = null, profFlush = null, profFlushSince = 0;
const PROFILE_ROWS = 1500; // a row without a thumbnail is ~430 chars; IndexedDB has room
const profilesDisk = () => (profBlob ||= wallet.loadFeatureState('profiles', {}));
- function flushProfiles() {
- profFlush = null;
+ function flushProfiles(force) {
+ // up to 5 s for the page to settle: stringifying this blob (thumbnails
+ // and all, up to ~2 MB) is a long main-thread task
+ if (!force && pageMoving() && Date.now() - (profFlushSince || 0) < 5000) { profFlush = setTimeout(flushProfiles, 300); return; }
+ profFlush = null; profFlushSince = 0;
const s = profBlob;
if (!s) return;
// Who stays when space runs out: your own faces, the people you follow,
@@ -848,7 +875,10 @@ export function messagesFeature(ctx) {
// Thumbnails are the bulk of this blob, so they live on a budget: the
// least recently seen strangers give theirs up first. The row itself
// stays — that face just paints the way it used to.
- let size = JSON.stringify(s).length;
+ // (an estimate, not a JSON.stringify of the lot just to measure it:
+ // a row is ~430 chars plus its thumbnail)
+ let size = 0;
+ for (const k in s) size += 430 + ((s[k] && s[k].thumb) || '').length;
if (size > THUMB_BUDGET) {
// never our own: the header face is the one that must paint offline
for (const k of evictFirst(Object.keys(s).filter((k) => s[k].thumb && !mine.has(k)))) {
@@ -875,7 +905,7 @@ export function messagesFeature(ctx) {
? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}),
...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1,
thumbFailVersion: p.thumbFailVersion || 0 } : {}) };
- if (!profFlush) profFlush = setTimeout(flushProfiles, 500);
+ if (!profFlush) { profFlushSince = Date.now(); profFlush = setTimeout(flushProfiles, 500); }
}
// A punk picture is OUR OWN art. Every coinos user who keeps the default
// publishes https://v3.coinos.io/punks/N.webp as their nostr picture, and
@@ -1081,6 +1111,39 @@ export function messagesFeature(ctx) {
blobProbing.set(url, task);
return task;
}
+ // Centre-crop to a square (the way background-size:cover paints it),
+ // shrink, and encode — off the main thread where the browser allows: the
+ // crop and resize through createImageBitmap, the encode through an
+ // OffscreenCanvas's convertToBlob. The synchronous canvas.toDataURL it
+ // replaces cost ~30 ms a face on a phone, mid-scroll (2026-11-07).
+ async function encodeThumb(bmp) {
+ const side = Math.min(bmp.width, bmp.height);
+ if (!side) throw new Error('empty');
+ const sx = (bmp.width - side) / 2, sy = (bmp.height - side) / 2;
+ if (typeof OffscreenCanvas !== 'undefined' && OffscreenCanvas.prototype.convertToBlob) {
+ let small = null;
+ try {
+ small = await createImageBitmap(bmp, sx, sy, side, side, { resizeWidth: THUMB_PX, resizeHeight: THUMB_PX, resizeQuality: 'high' });
+ const oc = new OffscreenCanvas(THUMB_PX, THUMB_PX);
+ oc.getContext('2d').drawImage(small, 0, 0);
+ const asUrl = (b) => new Promise((ok, bad) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => bad(r.error); r.readAsDataURL(b); });
+ let blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.75 });
+ if (blob.type !== 'image/webp') blob = await oc.convertToBlob({ type: 'image/jpeg', quality: 0.7 });
+ if (blob.size * 4 / 3 > THUMB_MAX) blob = await oc.convertToBlob({ type: 'image/webp', quality: 0.5 }); // a busy picture, leaned on harder
+ const data = await asUrl(blob);
+ if (data.length > THUMB_MAX) throw new Error('too big');
+ return data;
+ } finally { try { small && small.close(); } catch {} }
+ }
+ const c = document.createElement('canvas');
+ c.width = c.height = THUMB_PX;
+ c.getContext('2d').drawImage(bmp, sx, sy, side, side, 0, 0, THUMB_PX, THUMB_PX);
+ let data = c.toDataURL('image/webp', 0.75);
+ if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7);
+ if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5);
+ if (data.length > THUMB_MAX) throw new Error('too big');
+ return data;
+ }
function makeThumb(pk, p) {
if (!p || !p.picture || typeof document === 'undefined') return;
if (noCorsHost(p.picture)) return; // a host that won't hand over bytes; the <img> shows it anyway
@@ -1152,18 +1215,8 @@ export function messagesFeature(ctx) {
scheduleRepaint(); // the big face, where one is up, can paint from the copy now
}
bmp = await createImageBitmap(await res.blob());
- // centre-crop to a square, the way background-size:cover paints it
- const side = Math.min(bmp.width, bmp.height);
- if (!side) throw new Error('empty');
- const c = document.createElement('canvas');
- c.width = c.height = THUMB_PX;
- c.getContext('2d').drawImage(bmp, (bmp.width - side) / 2, (bmp.height - side) / 2,
- side, side, 0, 0, THUMB_PX, THUMB_PX);
- let data = c.toDataURL('image/webp', 0.75);
- if (!data.startsWith('data:image/webp')) data = c.toDataURL('image/jpeg', 0.7);
- if (data.length > THUMB_MAX) data = c.toDataURL('image/webp', 0.5); // a busy picture, leaned on harder
- if (data.length > THUMB_MAX) throw new Error('too big');
- done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX });
+ const data = await encodeThumb(bmp);
+ whenSettled(() => done({ thumb: data, thumbFor: url, thumbPx: THUMB_PX }));
} catch (e) {
// a quick TypeError is the CORS refusal (a dead host takes longer)
// only a refusal corsFetch confirmed (a body cut off mid-download
@@ -19062,7 +19115,7 @@ export function messagesFeature(ctx) {
follows = null; followsAt = 0; feed = null; relayLists = null;
mutes = null; mutesAt = 0;
stCache = null; stAt = 0; // the next account's state, not this one's
- if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped
+ if (profFlush) { clearTimeout(profFlush); flushProfiles(true); } // this account's faces, written before the blob is dropped
profBlob = null;
timedActivity.clear(); diskHeld.clear(); reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear();
noteCountsReady.clear();
diff --git a/src/features/withdraw-links.js b/src/features/withdraw-links.js
new file mode 100644
index 0000000..a63d180
--- /dev/null
+++ b/src/features/withdraw-links.js
@@ -0,0 +1,195 @@
+// Prepaid Lightning withdraw links (LNURL-withdraw): make a link or QR that
+// whoever holds it can pull sats from — a voucher, a faucet, a printed card —
+// even while this wallet is asleep. "Do we have lnurl withdraw links
+// somewhere? can we add them?" → "Prepaid" (Adam, 2026-11-07).
+//
+// The money is paid up front, from Spending over Lightning, to the coinos
+// registrar (coinosv3-server names/withdraw.js), which pays each claim out of
+// it; until claimed it sits there, and the creator can take back whatever is
+// left at any time ("Take back"), which closes the link. Links belong to the
+// wallet's own nostr key, so every device with the seed sees them.
+//
+// It lives on the Send page as a sibling of the gift link: ui.giftMode ===
+// 'lnurl' (giftMode is already a navigation field with its resets).
+import { nip98Header } from '../nip98.js';
+import { encodeLnurl } from '../lnurl.js';
+import { qrSvg } from '../qr.js';
+import { getNetwork } from '../api.js';
+import { t } from '../i18n.js';
+
+const REGISTRAR = 'https://names.coinos.io';
+const NOTE_MAX = 120;
+const net = () => (getNetwork() === 'mutinynet' ? 'mutinynet' : 'mainnet');
+const available = () => ['mainnet', 'mutinynet'].includes(getNetwork());
+
+export function withdrawLinks(ctx) {
+ const { h, ui, render, wallet, toast, copyBtn, goBack, fmtAmount, unitLabel, unitTag, parseAmount, getUnit } = ctx;
+ const hook = ctx.hook || (() => null);
+ const base = () => ctx.withdrawRegistrar || REGISTRAR;
+ // ui.wl: { list: [...]|null, listError, amount, uses, note, busy, error, open: id|null, made: id|null, taking: id|null }
+ const st = () => (ui.wl ||= { list: null, amount: '', uses: '1', note: '' });
+
+ const signer = { signEvent: (e) => wallet.nostrSign(e) };
+ async function call(method, path, payload) {
+ const url = base() + path;
+ const body = payload ? JSON.stringify(payload) : '';
+ const auth = await nip98Header(signer, url, method, body);
+ const r = await fetch(url, { method, headers: { authorization: auth, ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body } : {}) });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
+ return j;
+ }
+
+ async function load() {
+ const s = st();
+ try {
+ const j = await call('GET', '/withdraw/mine');
+ s.list = (j.links || []).filter((l) => l.net === net());
+ s.listError = '';
+ } catch (e) { s.listError = e.message; s.list ||= []; }
+ render();
+ }
+
+ function open() {
+ ui.wl = null;
+ ui.giftMode = 'lnurl';
+ ui.sendError = '';
+ st();
+ render();
+ load();
+ }
+
+ const lnurlOf = (l) => encodeLnurl(l.url).toUpperCase();
+ const amt = (sat) => fmtAmount(sat) + ' ' + unitLabel();
+
+ async function create() {
+ const s = st();
+ const sat = parseAmount(s.amount, getUnit()) || 0;
+ const uses = Math.floor(Number(s.uses) || 1);
+ s.error = '';
+ if (!(sat >= 1)) { s.error = t('wlAmountInvalid'); render(); return; }
+ if (!(uses >= 1 && uses <= 1000)) { s.error = t('wlUsesInvalid'); render(); return; }
+ if (wallet.watchOnly) { s.error = t('lnWithdrawWatchOnly'); render(); return; }
+ if (!hook('arkReady')) { s.error = t('hatNeedSpending'); render(); return; }
+ const total = sat * uses;
+ if ((hook('arkSpendableSat') || 0) < total + Math.max(10, Math.ceil(total * 0.002))) { s.error = t('wlNoFunds', { amount: amt(total) }); render(); return; }
+ s.busy = true; render();
+ try {
+ const inv = await call('POST', '/withdraw/new', { sat, uses, note: String(s.note || '').trim().slice(0, NOTE_MAX), net: net() });
+ await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.totalSat });
+ let link = null;
+ for (let i = 0; i < 10 && !link; i++) {
+ try { link = await call('POST', '/withdraw/funded', { id: inv.id }); }
+ catch (e) { if (!/not paid/i.test(e.message)) throw e; await new Promise((r) => setTimeout(r, 1500)); }
+ }
+ if (!link) throw new Error(t('wlNotConfirmed'));
+ s.list = [link, ...(s.list || []).filter((l) => l.id !== link.id)];
+ s.made = link.id; s.open = link.id;
+ s.amount = ''; s.uses = '1'; s.note = '';
+ } catch (e) {
+ s.error = e.message || String(e);
+ } finally { s.busy = false; render(); }
+ }
+
+ async function takeBack(l) {
+ const s = st();
+ s.taking = l.id; s.error = ''; render();
+ let lnId = null;
+ try {
+ if (l.remainingSat > 0) {
+ const inv = await hook('arkLnInvoice', l.remainingSat, t('wlTakeBackMemo'));
+ if (!inv || !inv.invoice) throw new Error(t('lnWithdrawNoInvoice'));
+ lnId = inv.id;
+ // the wallet has to claim the held payment while the registrar pays it
+ hook('arkLnWatch', inv.id, () => {});
+ const r = await call('POST', '/withdraw/reclaim', { id: l.id, invoice: inv.invoice });
+ Object.assign(l, r);
+ toast(r.paid === false ? t('wlTakeBackFailed') : t('wlTakenBack', { amount: amt(inv.amountSat) }));
+ } else {
+ Object.assign(l, await call('POST', '/withdraw/reclaim', { id: l.id }));
+ }
+ } catch (e) {
+ if (lnId) hook('arkLnCancel', lnId);
+ s.error = e.message || String(e);
+ } finally { s.taking = null; render(); load(); }
+ }
+
+ function linkCard(l) {
+ const s = st();
+ const lnurl = lnurlOf(l);
+ let svg = null;
+ try { svg = qrSvg(lnurl, { ec: 'M', mode: 'Alphanumeric' }); } catch {}
+ const live = l.status === 'open' && l.remainingSat > 0 && l.used < l.uses;
+ return h('div', { class: 'card col', style: 'align-items:center;gap:10px' },
+ h('h3', { style: 'align-self:stretch' }, s.made === l.id ? t('wlReady') : t('wlTitle')),
+ live && svg ? h('div', { html: svg }) : null,
+ l.note ? h('blockquote', { class: 'gift-note', style: 'width:100%' }, l.note) : null,
+ h('div', { class: 'small muted', style: 'text-align:center' },
+ l.uses > 1 ? t('wlTermsMany', { amount: amt(l.sat), n: l.uses - l.used, total: l.uses }) : t('wlTermsOne', { amount: amt(l.sat) }),
+ ' · ', t('wlLeft', { amount: amt(l.remainingSat) })),
+ live ? h('div', { class: 'addr-box break mono', style: 'width:100%;font-size:11px' }, lnurl) : h('div', { class: 'notice small' }, l.status === 'closed' ? t('wlClosed') : t('wlUsedUp')),
+ live ? h('div', { class: 'row gap6 wrap', style: 'width:100%' }, copyBtn(lnurl, t('wlCopy'))) : null,
+ live ? h('div', { class: 'small faint', style: 'text-align:center' }, t('wlBearer')) : null,
+ s.error && h('div', { class: 'notice err', style: 'width:100%' }, s.error),
+ live || (l.status === 'open' && l.remainingSat > 0)
+ ? (s.taking === l.id
+ ? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' }))
+ : h('button', { class: 'btn-ghost btn-block', onClick: () => takeBack(l) }, t('wlTakeBack', { amount: amt(l.remainingSat) })))
+ : null,
+ h('button', { class: 'btn-ghost btn-block', onClick: () => { s.open = null; s.made = null; s.error = ''; render(); } }, t('back')));
+ }
+
+ function formCard() {
+ const s = st();
+ const sat = parseAmount(s.amount, getUnit()) || 0;
+ const uses = Math.max(1, Math.floor(Number(s.uses) || 1));
+ return h('div', { class: 'card col gap6' },
+ h('h3', {}, t('wlTitle')),
+ h('div', { class: 'small muted' }, t('wlDesc')),
+ h('div', { class: 'input-group' },
+ h('input', { id: 'wl-amount', type: 'number', min: '0', inputmode: 'decimal', step: getUnit() === 'sats' ? '1' : '0.00000001',
+ placeholder: t('wlAmount'), value: s.amount, onInput: (e) => { s.amount = e.target.value; render(); } }),
+ h('div', { style: 'display:flex;align-items:center' }, unitTag())),
+ h('label', { class: 'row gap6', style: 'align-items:center' },
+ h('span', { class: 'small grow' }, t('wlUses')),
+ h('input', { id: 'wl-uses', type: 'number', min: '1', max: '1000', step: '1', inputmode: 'numeric', style: 'width:90px',
+ value: s.uses, onInput: (e) => { s.uses = e.target.value; render(); } })),
+ h('textarea', { class: 'gift-note-input', rows: 2, maxlength: NOTE_MAX, placeholder: t('wlNote'), value: s.note, onInput: (e) => { s.note = e.target.value; } }),
+ h('div', { class: 'small faint' }, sat >= 1 && uses > 1 ? t('wlPayNowMany', { total: amt(sat * uses), n: uses, amount: amt(sat) }) : t('wlPayNow')),
+ s.error && !s.open ? h('div', { class: 'notice err' }, s.error) : null,
+ s.busy
+ ? h('button', { class: 'btn-primary btn-block', disabled: true }, h('span', { class: 'spinner sm' }))
+ : h('button', { id: 'wl-create', class: 'btn-primary btn-block', onClick: create }, sat >= 1 ? t('wlCreateFor', { amount: amt(sat * uses) }) : t('wlCreate')));
+ }
+
+ function listCard() {
+ const s = st();
+ if (s.list == null) return h('div', { class: 'card', style: 'text-align:center' }, h('span', { class: 'spinner sm' }));
+ if (!s.list.length) return s.listError ? h('div', { class: 'notice err small' }, s.listError) : null;
+ return h('div', { class: 'card col gap6' },
+ h('span', { class: 'small muted' }, t('wlYours')),
+ ...s.list.map((l) => h('button', { class: 'row between wl-row', style: 'text-align:left', onClick: () => { s.open = l.id; s.made = null; s.error = ''; render(); } },
+ h('span', { class: 'small' }, amt(l.sat), l.uses > 1 ? ` × ${l.uses}` : '', l.note ? h('span', { class: 'faint' }, ' · ' + l.note) : null),
+ h('span', { class: 'small faint' },
+ l.status === 'closed' ? t('wlClosedTag') : l.remainingSat < 1 || l.used >= l.uses ? t('wlUsedUpTag') : t('wlLeft', { amount: amt(l.remainingSat) })))));
+ }
+
+ function view() {
+ const s = st();
+ if (!available()) {
+ return h('div', { class: 'col', style: 'gap:12px' },
+ h('div', { class: 'card col' }, h('h3', {}, t('wlTitle')), h('div', { class: 'small muted' }, t('wlMainnetOnly'))),
+ h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.giftMode = false; }) }, t('back')));
+ }
+ const l = s.open && (s.list || []).find((x) => x.id === s.open);
+ if (l) return h('div', { class: 'col', style: 'gap:12px' }, linkCard(l));
+ return h('div', { class: 'col', style: 'gap:12px' },
+ formCard(),
+ listCard(),
+ h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.wl = null; goBack(() => { ui.giftMode = false; }); } }, t('back')));
+ }
+
+ const entry = () => h('button', { id: 'wl-open', class: 'linklike small', style: 'align-self:center;margin-top:2px', onClick: open }, '⚡ ' + t('wlTitle'));
+
+ return { view, entry, open };
+}