diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 65 | ||||
| -rw-r--r-- | src/features/zaps.js | 10 |
2 files changed, 71 insertions, 4 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 6c4f5f8..4ba4bfb 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4375,6 +4375,8 @@ export function messagesFeature(ctx) { if (persist) persistThread(c); } const tagOf = (ev, k) => (ev.tags.find((x) => x[0] === k) || [])[1]; + const APP_KIND = 32267; // a Zapstore app listing (NIP-82): zaps on one are zaps on your app + const APP_RELAYS = ['wss://relay.zapstore.dev']; function receiptSats(ev) { if (ev.kind === 9737) { const net = tagOf(ev, 'network'); @@ -4395,6 +4397,16 @@ export function messagesFeature(ctx) { if (ev.kind === 9737) return String(ev.content || '').slice(0, 200); try { return String((JSON.parse(tagOf(ev, 'description') || 'null') || {}).content || '').slice(0, 200); } catch { return ''; } } + // The address (kind:pubkey:d) a zap was for, when it wasn't a note: a + // stream (30311) or a Zapstore app listing (32267). LNURL servers often + // leave the a tag off the receipt (Zapstore's did, 2026-11-07), so the zap + // request inside it is asked too. + function zapAddrOf(ev) { + const a = (e) => ((e && e.tags) || []).find((x) => x[0] === 'a' && x[1]); + const own = a(ev); + if (own) return own[1]; + try { const r = a(JSON.parse(tagOf(ev, 'description') || 'null')); return r ? r[1] : null; } catch { return null; } + } function zapperOf(ev) { if (ev.kind === 9737) { // Signed by the wallet key; the sender tag names the person behind it. @@ -18455,9 +18467,11 @@ export function messagesFeature(ctx) { if (ZAP_KINDS.includes(ev.kind)) { const actor = zapperOf(ev), sats = receiptSats(ev); if (!actor || my.includes(actor) || !sats) return null; - // a zap on a stream names it by address (a 30311), not by an e tag - const stream = !lastE ? ((ev.tags || []).find((x) => x[0] === 'a' && String(x[1] || '').startsWith(LIVE_KIND + ':')) || [])[1] || null : null; - return { id: ev.id, what: 'zap', actor, target: lastE, stream, hint, sats, text: zapText(ev), ts: ev.created_at }; + // a zap on a stream or an app names it by address, not by an e tag + const addr = !lastE ? zapAddrOf(ev) : null; + const stream = addr && addr.startsWith(LIVE_KIND + ':') ? addr : null; + const app = addr && addr.startsWith(APP_KIND + ':') ? addr : null; + return { id: ev.id, what: 'zap', actor, target: lastE, stream, ...(app ? { app } : {}), hint, sats, text: zapText(ev), ts: ev.created_at }; } if (my.includes(ev.pubkey)) return null; if (ev.kind === 7) { @@ -18494,6 +18508,12 @@ export function messagesFeature(ctx) { const old = c.items.find((y) => y.id === x.id); if (old && !old.emojiUrl) { old.emojiUrl = x.emojiUrl; old.emoji = x.emoji; healed = true; } } + // ...and a zap on an app saved before rows knew one + for (const x of items) { + if (!x || !x.app || !known.has(x.id)) continue; + const old = c.items.find((y) => y.id === x.id); + if (old && !old.app) { old.app = x.app; healed = true; } + } const add = items.filter((x) => x && !hiddenPk(x.actor) && !known.has(x.id) && known.add(x.id)); if (!add.length) { if (healed) persistNotifs(); return healed; } warmNotifFaces(add); @@ -18611,7 +18631,32 @@ export function messagesFeature(ctx) { return ref && ref.type === 'pubkey' ? '@' + displayName(ref.pk) : m.slice(6, 18) + '\u2026'; }).replace(/\s+/g, ' ').trim(); } + // Zapstore app listings (NIP-82 kind 32267): a zap on one is a zap on your + // app. Its name is asked of Zapstore's relay once per session. + const appIdOf = (addr) => String(addr || '').split(':').slice(2).join(':'); + const appNames = new Map(); // app id -> name | null (asking / none) + function appName(addr) { + const id = appIdOf(addr); + if (!id) return null; + if (!appNames.has(id)) { + appNames.set(id, null); + queryOn(APP_RELAYS, { kinds: [APP_KIND], '#d': [id], limit: 5 }, 5000).then((evs) => { + const ev = (evs || []).sort((a, b) => b.created_at - a.created_at)[0]; + const name = ev && tagOf(ev, 'name'); + if (name) { appNames.set(id, String(name).slice(0, 60)); scheduleRepaint(); } + }).catch(() => {}); + } + return appNames.get(id); + } + const openApp = (addr) => { + const url = 'https://zapstore.dev/apps/' + encodeURIComponent(appIdOf(addr)); + if (ctx.openExternal) ctx.openExternal(url); else window.open(url, '_blank', 'noopener'); + }; function notifLabel(x) { + if (x.what === 'zap' && x.app) { + const name = appName(x.app); + return name ? t('alertZapApp', { name, sats: fmtSats(x.sats) + ' sats' }) : t('alertZapAppAnon', { sats: fmtSats(x.sats) + ' sats' }); + } if (x.what === 'zap') return t('alertZap', { sats: fmtSats(x.sats) + ' sats' }); if (x.what === 'react') { const code = shortcodeOf(x.emoji); @@ -18649,6 +18694,20 @@ export function messagesFeature(ctx) { // a zap on a stream opens the stream; one on your profile (no post // at all) opens the zapper's — the tap used to do nothing else if (x.stream) { const [, pk, ...d] = x.stream.split(':'); stopNotifWatch(); openLiveRef({ pk, d: d.join(':') }).catch(() => {}); } + else if (x.app) openApp(x.app); + // a row saved before rows read the zap request's address: look at + // the receipt once, then go where it says + else if (x.what === 'zap' && !x.addrChecked) { + x.addrChecked = true; + queryOn(zapRelays(), { ids: [x.id] }, 4500).catch(() => []).then((evs) => { + const addr = evs && evs[0] ? zapAddrOf(evs[0]) : null; + if (addr && addr.startsWith(APP_KIND + ':')) x.app = addr; + else if (addr && addr.startsWith(LIVE_KIND + ':')) x.stream = addr; + persistNotifs(); + render(); + open(); + }); + } else { stopNotifWatch(); openProfile(x.actor); } } }; diff --git a/src/features/zaps.js b/src/features/zaps.js index 8ac78fb..8e29409 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -533,7 +533,15 @@ export function zapsFeature(ctx) { // a login challenge needs no balance, only the wallet's key const auth = parseLnurlAuth(text); if (auth) { beginLogin(auth); return true; } - if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false; + if (!ui.send || ui.send.recipients.length !== 1) return false; + // An LNURL may be a withdraw — it pays US, so it needs no spendable + // balance, and right after a refresh (Spending still connecting) the + // check below turned a scanned withdraw link into a "Zap LNURL…" + // recipient (Adam, 2026-11-08). Only the GET can tell: take it now, + // and a pay one meets the pay checks where it's paid. + const early = parseZapTarget(text); + if (early && early.kind === 'lnurl' && !typed) { begin(early, zapDisplay(early, text), true); return true; } + if (!canPay()) return false; const tap = !typed; // scanned/pasted (a tap), not a keystroke const nof = maybeNoffer(text); if (nof) { |
