speed improvements for zaps

This commit is contained in:
Aljaz Ceru
2026-08-17 11:19:59 +02:00
parent a85b3873bf
commit 8914120b8a
3 changed files with 92 additions and 16 deletions
+1
View File
@@ -28,6 +28,7 @@
<meta name="twitter:image" content="https://your-domain.com/path-to-image.jpg">
<title>Awesome Nostr Resources | Comprehensive Guide to Nostr Protocol</title>
<link rel="preconnect" href="https://esm.sh" crossorigin>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
</head>
+3
View File
@@ -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; }
+88 -16
View File
@@ -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<params>
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 <strong>${escapeHtml(name)}</strong>…`, '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 <strong>${escapeHtml(name)}</strong> ${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 @@
</div>`;
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 });
})();