diff --git a/index.html b/index.html index 21bd51a..cc6d301 100644 --- a/index.html +++ b/index.html @@ -116,7 +116,8 @@ diff --git a/zap.js b/zap.js index 85dcee6..22a454b 100644 --- a/zap.js +++ b/zap.js @@ -6,6 +6,7 @@ (() => { const ZAP_SATS = 1000; // default zap amount for chips + const ZAP_MEMO = 'nostr.net zap'; // so recipients know where it came from const ZAP_RELAYS = ['wss://relay.nostr.net', 'wss://relay.damus.io', 'wss://nos.lol']; const NOBLE_URL = 'https://esm.sh/@noble/curves@1.4.0/secp256k1'; // lazy: only for anon zap receipts const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; @@ -78,7 +79,12 @@ async function fetchInvoice(params, msat, zapEvent) { const cb = new URL(params.callback); cb.searchParams.set('amount', String(msat)); - if (zapEvent) cb.searchParams.set('nostr', JSON.stringify(zapEvent)); + if (zapEvent) { + cb.searchParams.set('nostr', JSON.stringify(zapEvent)); + } else if (params.commentAllowed) { + // plain payment (no zap support): memo via LNURL comment param + cb.searchParams.set('comment', ZAP_MEMO.slice(0, params.commentAllowed)); + } const r = await fetch(cb); const j = await r.json(); if (j.status === 'ERROR') throw new Error(j.reason || 'invoice error'); @@ -110,7 +116,7 @@ const pubkey = await window.nostr.getPublicKey(); const ev = await window.nostr.signEvent({ kind: 9734, pubkey, created_at: Math.floor(Date.now() / 1000), - tags, content: '' + tags, content: ZAP_MEMO }); return { event: { ...ev, pubkey: ev.pubkey || pubkey }, anon: false, attributed: true }; } catch (e) { @@ -124,7 +130,7 @@ const priv = hex(crypto.getRandomValues(new Uint8Array(32))); const pubkey = hex(schnorr.getPublicKey(priv)); const created_at = Math.floor(Date.now() / 1000); - const event = { pubkey, created_at, kind: 9734, tags: [...tags, ['anon', '']], content: '' }; + const event = { pubkey, created_at, kind: 9734, tags: [...tags, ['anon', '']], content: ZAP_MEMO }; const id = await sha256Hex(JSON.stringify([0, pubkey, created_at, 9734, event.tags, ''])); event.id = id; event.sig = hex(schnorr.sign(unhex(id), priv)); @@ -136,24 +142,36 @@ } // ---------- payment ---------- - const weblnReady = () => !!(window.webln && typeof window.webln.sendPayment === 'function'); + // Provider sources: ① window.webln (extension) ② Bitcoin Connect's + // connected provider (NWC / LNC / LNbits) — handed to us via onConnected(), + // it is NOT set on window.webln. + let bcProvider = null; - // enable() once per session, started as early as possible (prompt shows - // immediately on click instead of after the lnurlp/invoice round trips) + const getProvider = () => { + if (window.webln && typeof window.webln.sendPayment === 'function') return window.webln; + if (bcProvider && typeof bcProvider.sendPayment === 'function') return bcProvider; + return null; + }; + const weblnReady = () => !!getProvider(); + + // enable() once per provider+session, started as early as possible (prompt + // shows immediately on click instead of after the lnurlp/invoice round trips) let enablePromise = null; - const ensureEnabled = () => { + let enabledFor = null; + const ensureEnabled = (provider) => { + if (enabledFor !== provider) { enablePromise = null; enabledFor = provider; } if (!enablePromise) { enablePromise = (async () => { - if (window.webln?.enable) { try { await window.webln.enable(); } catch (e) { /* already enabled */ } } + if (provider?.enable) { try { await provider.enable(); } catch (e) { /* already enabled */ } } })(); } return enablePromise; }; - async function payInvoice(pr) { - if (!weblnReady()) throw new Error('wallet not connected'); - await ensureEnabled(); - return window.webln.sendPayment(pr); + async function payInvoice(pr, provider) { + if (!provider?.sendPayment) throw new Error('wallet not connected'); + await ensureEnabled(provider); + return provider.sendPayment(pr); } // ---------- lnurlp params (cached + prefetched on hover) ---------- @@ -172,11 +190,13 @@ try { toast(`⚡ Zapping ${escapeHtml(name)}…`, 'info', 9000); + const provider = getProvider(); + if (!provider) throw new Error('wallet not connected'); // 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() + ensureEnabled(provider) ]); let msat = ZAP_SATS * 1000; @@ -186,7 +206,7 @@ const { event, anon } = await buildZapRequest(params, msat, lnAddress); const inv = await fetchInvoice(params, msat, event); - await payInvoice(inv.pr); + await payInvoice(inv.pr, provider); if (chip) { chip.dataset.state = 'zapped'; delete chip.dataset.zapping; } const sats = msat / 1000; @@ -224,6 +244,12 @@ // ---------- connect-modal path ---------- function openConnectModal() { + // preferred: the library's own API (module sets window.bitcoinConnect) + if (typeof window.bitcoinConnect?.launchModal === 'function') { + window.bitcoinConnect.launchModal(); + return true; + } + // fallback: click the real button inside the shadow root const btn = document.querySelector('bc-button'); if (!btn) return false; const inner = btn.shadowRoot?.querySelector('bci-button'); @@ -231,6 +257,8 @@ return true; } + // polls for extension-provided window.webln (Bitcoin Connect's NWC path + // is handled by the onConnected subscription instead) function watchForConnection() { clearInterval(pollTimer); pollTimer = setInterval(() => { @@ -357,6 +385,49 @@ setTimeout(markReady, 1500); window.addEventListener('bc:onpaid', markReady); + // ---------- Bitcoin Connect wiring (NWC / LNC / LNbits) ---------- + // BC never sets window.webln — the connected provider arrives via + // onConnected(provider). The module (loaded as type=module, after this + // classic script) exposes window.bitcoinConnect, so retry until it lands. + let bcSubscribed = false; + function trySubscribeBc() { + if (bcSubscribed) return; + const api = window.bitcoinConnect; + if (!api || typeof api.onConnected !== 'function') return; + bcSubscribed = true; + try { + // fires immediately if a wallet is already connected (page reload), + // and again on every new connection + api.onConnected(provider => { + if (provider && typeof provider.sendPayment === 'function') { + bcProvider = provider; + enabledFor = null; // re-enable for the new provider + document.body.classList.add('webln-ready'); + toast('Wallet connected ⚡', 'success', 2500); + if (pendingZap) { + const p = pendingZap; pendingZap = null; + setTimeout(() => zap(p.lnAddress, p.name, p.chip), 200); + } + } + }); + if (typeof api.onDisconnected === 'function') { + api.onDisconnected(() => { + bcProvider = null; + enabledFor = null; + document.body.classList.remove('webln-ready'); + }); + } + } catch (e) { + console.warn('Bitcoin Connect subscription failed', e); + bcSubscribed = false; + } + } + let bcTries = 0; + const bcWatch = setInterval(() => { + trySubscribeBc(); + if (bcSubscribed || ++bcTries > 80) clearInterval(bcWatch); // ~20s max + }, 250); + // ---------- warm the caches so first zap / first dialog are instant ---------- const warmup = () => { loadQr().catch(() => {}); // QR generator (tiny)