From 8914120b8a1ffe935fb11f5bb2975e58d13c1267 Mon Sep 17 00:00:00 2001 From: Aljaz Ceru Date: Mon, 17 Aug 2026 11:19:59 +0200 Subject: [PATCH] speed improvements for zaps --- index.html | 1 + styles.css | 3 ++ zap.js | 104 ++++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 92 insertions(+), 16 deletions(-) diff --git a/index.html b/index.html index ae986f8..21bd51a 100644 --- a/index.html +++ b/index.html @@ -28,6 +28,7 @@ Awesome Nostr Resources | Comprehensive Guide to Nostr Protocol + diff --git a/styles.css b/styles.css index 90a48b9..2cc1299 100644 --- a/styles.css +++ b/styles.css @@ -1287,3 +1287,6 @@ body.webln-ready .zap-chip { white-space: nowrap; flex-shrink: 0; } + +.zap-as-me { color: var(--link-color, #7c5cbf); font-weight: 500; } +.zap-as-me:hover { text-decoration: underline; } diff --git a/zap.js b/zap.js index 514dbf9..85dcee6 100644 --- a/zap.js +++ b/zap.js @@ -87,6 +87,13 @@ } // ---------- NIP-57 zap request ---------- + // Anonymous by default (fast: no extension signing prompt per zap). + // Users can opt in to attributed zaps via the "zap as me" link — remembered. + const SIGN_AS_ME_KEY = 'grantless:signAsMe'; + const signAsMe = () => + localStorage.getItem(SIGN_AS_ME_KEY) === '1' && + !!(window.nostr?.signEvent && window.nostr?.getPublicKey); + async function buildZapRequest(params, msat, address) { if (!params.allowsNostr || !params.nostrPubkey) return { event: null, anon: false }; @@ -97,15 +104,15 @@ ['lnurl', lnurlEncode(lnurlpUrl(address))] ]; - // Prefer the user's real identity via NIP-07… - if (window.nostr?.signEvent && window.nostr?.getPublicKey) { + // Attributed zap — only when the user opted in (avoids a signer prompt per zap) + if (signAsMe()) { try { const pubkey = await window.nostr.getPublicKey(); const ev = await window.nostr.signEvent({ kind: 9734, pubkey, created_at: Math.floor(Date.now() / 1000), tags, content: '' }); - return { event: { ...ev, pubkey: ev.pubkey || pubkey }, anon: false }; + return { event: { ...ev, pubkey: ev.pubkey || pubkey }, anon: false, attributed: true }; } catch (e) { console.warn('NIP-07 signing failed, falling back to anon zap', e); } @@ -131,12 +138,33 @@ // ---------- payment ---------- const weblnReady = () => !!(window.webln && typeof window.webln.sendPayment === 'function'); + // enable() once per session, started as early as possible (prompt shows + // immediately on click instead of after the lnurlp/invoice round trips) + let enablePromise = null; + const ensureEnabled = () => { + if (!enablePromise) { + enablePromise = (async () => { + if (window.webln?.enable) { try { await window.webln.enable(); } catch (e) { /* already enabled */ } } + })(); + } + return enablePromise; + }; + async function payInvoice(pr) { if (!weblnReady()) throw new Error('wallet not connected'); - if (window.webln.enable) { try { await window.webln.enable(); } catch (e) { /* already enabled */ } } + await ensureEnabled(); return window.webln.sendPayment(pr); } + // ---------- lnurlp params (cached + prefetched on hover) ---------- + const lnurlCache = new Map(); // address → Promise + function fetchLnurlParamsCached(address) { + if (!lnurlCache.has(address)) { + lnurlCache.set(address, fetchLnurlParams(address).catch(e => { lnurlCache.delete(address); throw e; })); + } + return lnurlCache.get(address); + } + // ---------- main zap flow ---------- async function zap(lnAddress, name, chip) { if (chip?.dataset.zapping === '1') return; @@ -144,7 +172,12 @@ try { toast(`⚡ Zapping ${escapeHtml(name)}…`, 'info', 9000); - const params = await fetchLnurlParams(lnAddress); + // kick off wallet permission + lnurlp in parallel — the wallet's + // approval prompt (if any) shows right away, not after the fetches + const [params] = await Promise.all([ + fetchLnurlParamsCached(lnAddress), + weblnReady() ? ensureEnabled() : Promise.resolve() + ]); let msat = ZAP_SATS * 1000; const min = params.minSendable || 1000, max = params.maxSendable || Infinity; @@ -163,6 +196,23 @@ : `⚡ Sent ${escapeHtml(name)} ${sats} sats (no zap receipt)`, 'success', 6000 ); + // one-time opt-in for attributed zaps (NIP-07 available but not enabled) + if (anon && window.nostr?.signEvent && localStorage.getItem(SIGN_AS_ME_KEY) !== '1') { + const t = document.querySelector('.zap-toast'); + if (t) { + const a = document.createElement('a'); + a.href = '#'; + a.className = 'zap-as-me'; + a.textContent = 'zap as me instead'; + a.addEventListener('click', (ev) => { + ev.preventDefault(); + localStorage.setItem(SIGN_AS_ME_KEY, '1'); + toast('Zaps will now be signed with your npub ⚡', 'success', 3000); + }); + t.appendChild(document.createTextNode(' · ')); + t.appendChild(a); + } + } } catch (err) { console.error('zap failed:', err); if (chip) { chip.dataset.state = 'error'; delete chip.dataset.zapping; setTimeout(() => { if (chip.dataset.state === 'error') delete chip.dataset.state; }, 2500); } @@ -198,11 +248,23 @@ // ---------- QR fallback dialog (no wallet connection required) ---------- let qrLibPromise = null; + const qrCache = new Map(); // address → svg string async function loadQr() { if (!qrLibPromise) qrLibPromise = import('https://esm.sh/qrcode-generator@1.4.4').then(m => m.default || m); return qrLibPromise; } + async function qrSvgFor(address) { + if (qrCache.has(address)) return qrCache.get(address); + const q = await loadQr(); + const qr = q(0, 'M'); + qr.addData('lightning:' + lnurlEncode(lnurlpUrl(address)).toUpperCase(), 'Byte'); + qr.make(); + const svg = qr.createSvgTag({ cellSize: 4, margin: 0 }); + qrCache.set(address, svg); + return svg; + } + const closeZapModal = () => document.querySelector('.zap-modal-backdrop')?.remove(); async function showZapModal({ lnAddress, name, chip }) { @@ -230,17 +292,10 @@ `; document.body.appendChild(backdrop); - // QR: LNURL-pay encoded as lightning: URI (static, zero network) - try { - const q = await loadQr(); - const qr = q(0, 'M'); - qr.addData('lightning:' + lnurlEncode(lnurlpUrl(lnAddress)).toUpperCase(), 'Byte'); - qr.make(); - backdrop.querySelector('.zap-qr').innerHTML = qr.createSvgTag({ cellSize: 4, margin: 0 }); - } catch (e) { - console.warn('QR generation failed', e); - backdrop.querySelector('.zap-qr-box')?.remove(); - } + // QR: LNURL-pay encoded as lightning: URI (static, zero network, cached per address) + qrSvgFor(lnAddress) + .then(svg => { const box = backdrop.querySelector('.zap-qr'); if (box) box.innerHTML = svg; }) + .catch(e => { console.warn('QR generation failed', e); backdrop.querySelector('.zap-qr-box')?.remove(); }); // wiring backdrop.querySelector('.zap-modal-close').addEventListener('click', closeZapModal); @@ -301,4 +356,21 @@ markReady(); setTimeout(markReady, 1500); window.addEventListener('bc:onpaid', markReady); + + // ---------- warm the caches so first zap / first dialog are instant ---------- + const warmup = () => { + loadQr().catch(() => {}); // QR generator (tiny) + import(NOBLE_URL).catch(() => {}); // schnorr signer for anon receipts + }; + warmup(); // immediately — modules are tiny and this keeps the first click instant + + // prefetch lnurlp params on hover so the invoice request starts instantly on click + document.addEventListener('pointerover', (e) => { + const chip = e.target.closest?.('a.zap-chip, #fundButton'); + if (!chip) return; + const href = chip.getAttribute('href') || ''; + if (!href.startsWith('lightning:')) return; + const addr = chip.dataset.lnAddress || href.replace(/^lightning:/i, ''); + if (addr.includes('@')) fetchLnurlParamsCached(addr).catch(() => {}); + }, { passive: true }); })();