diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 227 | ||||
| -rw-r--r-- | src/features/zaps.js | 20 |
2 files changed, 233 insertions, 14 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 0974839..02e418d 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7844,7 +7844,7 @@ export function messagesFeature(ctx) { // A bare npub/nprofile — with or without an @ in front, no nostr: prefix — // is how Vector (and people) write a mention; it resolves to the name too. const BARE_KEY = '(?<![\\w/])@?(?:npub|nprofile)1[a-z0-9]{20,}(?![\\w])'; - const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi'); + const NOTE_SPLIT = new RegExp('(' + MD_LINK + '|https?:\\/\\/[^\\s]+|wss?:\\/\\/[^\\s]+|nostr:(?:npub|nprofile|note|nevent|naddr)1[a-z0-9]+|' + BARE_KEY + '|' + MENTION + '|' + HASHTAG + ')', 'gi'); const MD_PARTS = new RegExp('^(!?)\\[([^\\]\\n]{0,300})\\]\\(\\s*<?(https?:\\/\\/[^\\s>)]+)>?[^)\\n]{0,300}\\)$', 'i'); // A YouTube link is a video, so show the video. All three shapes it comes @@ -8911,8 +8911,57 @@ export function messagesFeature(ctx) { w.stops.push(subscribeOn(relays, { kinds: [LIVE_CHAT_KIND, 9735, 9737], '#a': [s.a], limit: 200 }, add)); w.stops.push(subscribeOn(relays, { kinds: [7, 5], '#a': [s.a], limit: 1000 }, e => takeLiveAction(w, e))); w.stops.push(subscribeOn(relays, { kinds: [LIVE_KIND], authors: [pk], '#d': [d] }, (e) => { if (takeLive([e])) scheduleRepaint(); })); + // who's here: NIP-53 presence (kind 10312, the stream in its a tag) — + // Shosho, zap.stream and others say it, and so do we while it's open + w.here = new Map(); // pubkey -> when they last said so + w.stops.push(subscribeOn(relays, { kinds: [PRESENCE_KIND], '#a': [s.a], since: Math.floor(Date.now() / 1000) - PRESENCE_FRESH }, (e) => { + if (liveWatch !== w || (w.here.get(e.pubkey) || 0) >= e.created_at) return; + w.here.set(e.pubkey, e.created_at); + scheduleRepaint(); + })); + sayHere(w); return w; } + // Presence counts for half an hour (some clients say it once, on joining); + // ours is said again every five minutes while the stream is open, and + // taken back (an empty one) on leaving. Only with a key held here: a + // signer app would be asked to sign every five minutes. + const PRESENCE_KIND = 10312, PRESENCE_FRESH = 1800; + async function presence(w, here) { + const id = await identity().catch(() => null); + if (!id || !(id.signer instanceof Uint8Array) || isVisitor()) return; + const ev = liveSeen.get(w.a); + if (here && (!ev || liveHost(ev) === id.pubkey || !isLiveNow(ev))) return; // not for your own, nor one that's over + const evt = finalizeEvent({ kind: PRESENCE_KIND, created_at: Math.floor(Date.now() / 1000), content: '', + tags: here ? [['a', w.a, w.relays[0] || '', 'root'], CLIENT_TAG] : [CLIENT_TAG] }, id.signer); + if (here) { w.here.set(evt.pubkey, evt.created_at); scheduleRepaint(); w.said = true; } + publishOn(w.relays, evt).catch(() => {}); + } + function sayHere(w) { + presence(w, true); + w.hereTimer = setInterval(() => { if (liveWatch === w) presence(w, true); }, 5 * 60_000); + } + // the people known to be watching (fresh presence), newest first + const watchersOf = (w) => { + const since = Date.now() / 1000 - PRESENCE_FRESH; + return w && w.here ? [...w.here].filter(([, at]) => at >= since).sort((a, b) => b[1] - a[1]).map(([pk]) => pk) : []; + }; + // "4 watching": hover names who (as many as are known), a tap shows their faces + function watchingLine(w, n, cls = 'small muted') { + const who = watchersOf(w); + const names = who.slice(0, 30).map((pk) => displayName(pk)).join(', ') + (who.length > 30 ? '…' : ''); + return h('span', { class: cls + (who.length ? ' live-watching has-who' : ' live-watching'), title: who.length ? names : undefined, + role: who.length ? 'button' : undefined, + onClick: who.length ? (e) => { e.stopPropagation(); w.showWho = !w.showWho; render(); } : undefined }, + t('liveWatching', { n: Math.max(n || 0, who.length) })); + } + function watchersRow(w) { + const who = watchersOf(w); + if (!w || !w.showWho || !who.length) return null; + return h('div', { class: 'live-who', 'data-key': 'live-who' }, + ...who.slice(0, 60).map((pk) => h('button', { type: 'button', class: 'live-who-p', 'data-key': 'who:' + pk, title: displayName(pk), onClick: () => openProfile(pk) }, + avatar(pk, 'chat-avatar mini', false), h('span', { class: 'small' }, displayName(pk))))); + } // Include reactions/receipts from clients that only tag the comment's e, // plus deletions of known reactions. Batch changes into one subscription. function watchLiveActions(w) { @@ -9028,6 +9077,8 @@ export function messagesFeature(ctx) { if (w.actionStop) w.actionStop(); if (w.sheet) ui.emojiPick = null; for (const stop of w.stops) { try { stop(); } catch {} } + clearInterval(w.hereTimer); + if (w.said) presence(w, false); // gone stopLivePlayer(w); } // What to play: the stream while it's on, its recording once it's over. @@ -9222,7 +9273,7 @@ export function messagesFeature(ctx) { liveBadge(ev)), mine ? h('div', { class: 'row gap6 bc-bar', style: 'align-items:center' }, h('span', { class: 'bc-clock' }, clockOf(Date.now() / 1000 - bc.starts)), - h('span', { class: 'small muted' }, t('liveWatching', { n: bc.viewers || 0 })), + watchingLine(w, bc.viewers || 0), bc.reconnecting ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveReconnecting')) : bc.behind ? h('span', { class: 'small', style: 'color:var(--red)' }, t('goLiveBehind')) : null, // camera ⇄ screen, without stopping the stream @@ -9244,7 +9295,7 @@ export function messagesFeature(ctx) { h('div', { class: 'row gap6', style: 'align-items:center;flex-wrap:wrap' }, avatar(host, 'chat-avatar mini'), h('a', { href: '#', class: 'quote-name', onClick: (e) => { e.preventDefault(); openProfile(host); } }, displayName(host)), - n != null && status === 'live' ? h('span', { class: 'small muted' }, t('liveWatching', { n })) : null, + n != null && status === 'live' ? watchingLine(w, n) : null, h('span', { style: 'margin-left:auto' }), canZapPk(host) ? h('button', { class: 'btn-sm', onClick: (e) => { setZapAddr(ev.id, s.a); zapNote(host, ev, e.currentTarget.getBoundingClientRect()); } }, '⚡ ' + t('liveZap')) : null, // ours to answer for: anyone signed in can report it, an admin can take it down @@ -9260,6 +9311,7 @@ export function messagesFeature(ctx) { const url = location.origin + '/' + packNaddr(s.a); try { if (navigator.share) await navigator.share({ url }); else { await navigator.clipboard.writeText(url); toast(t('copied')); } } catch {} } }, h('span', { style: 'display:flex', html: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/></svg>' }))), + watchersRow(w), liveSummary(ev) ? h('div', { class: 'small muted', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, liveSummary(ev).slice(0, 600)) : null, ui.liveReport && ui.liveReport.a === s.a ? liveReportPanel(ev) : null), h('div', { class: 'card col', style: 'gap:8px' }, @@ -10377,6 +10429,14 @@ export function messagesFeature(ctx) { } else if (/^https?:\/\//i.test(part)) { const pack = PACK_LINK_RE.exec(part); out.push(pack ? packLinkNode(part, pack[1]) : urlNode(part)); + } else if (/^wss?:\/\//i.test(part)) { + // a relay named in a post opens what's on it (the raw firehose) + const [, addr, tail] = /^(.*?)([.,;:!?)\]'"]*)$/.exec(part); + const relay = normRelay(addr); + out.push(relay ? h('a', { + href: '/feed/all?r=' + encodeURIComponent(relay), title: t('relayLinkOpen'), + onClick: (e) => { e.preventDefault(); e.stopPropagation(); openFeedUrl('all', '?r=' + encodeURIComponent(relay)); }, + }, addr) : addr, tail); } else if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) { const bare = part.replace(/^(nostr:|@)/i, ''); const ref = parseNostrRef(bare); @@ -14077,6 +14137,7 @@ export function messagesFeature(ctx) { for (const u of missing) tileLooked.add(u); if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u)))); } + if (mediaMode === 'videos') for (const it of items.slice(0, 24)) preconnect(playSrc(it.url)); return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode }, ...items.map((it) => { const k = mediaKey(it); @@ -14091,6 +14152,7 @@ export function messagesFeature(ctx) { return h('button', { class: 'media-tile' + (mediaMode === 'videos' ? ' video' : ''), type: 'button', 'data-key': 'tile:' + k, 'aria-label': displayName(it.ev.pubkey), onClick: () => openMediaPager(mediaMode, k), + onPointerDown: mediaMode === 'videos' ? () => earlyPlayer(it.url) : undefined, }, mediaMode === 'images' || poster ? h('img', { src: poster || tileThumbs.get(it.url) || mediaSrc(it.url), alt: '', draggable: 'false', @@ -14166,6 +14228,51 @@ export function messagesFeature(ctx) { } } + // Opening a clip from the grid: on Adam's phone a player made at the tap + // took ~0.65 s to move (making it ~0.25, its first frame ~0.15, starting + // its sound ~0.3 — the clip's opening already in the cache saved nothing), + // and 1.8 s when the host's connection had to be made too. So the grid's + // clip hosts are connected to ahead (a preconnect: cheap), and the + // player starts loading when the finger comes down on the tile — the + // pager adopts that same element (pagerSlide), so the load overlaps the + // tap and the pager's opening. One the tap doesn't follow (a scroll) is + // let go. + const preconnected = new Set(); + function preconnect(url) { + let origin; try { origin = new URL(url, location.href).origin; } catch { return; } + if (origin === location.origin || preconnected.has(origin) || preconnected.size > 24 || typeof document === 'undefined') return; + preconnected.add(origin); + const l = document.createElement('link'); + l.rel = 'preconnect'; l.href = origin; + document.head.append(l); + } + let early = null; // { url, v, timer } + function earlyPlayer(url) { + if (frugalData() || (early && early.url === url)) return; + dropEarly(); + const v = document.createElement('video'); + v.playsInline = true; v.loop = true; v.preload = 'auto'; v.muted = true; + v.setAttribute('src', playSrc(url)); + v.dataset.early = '1'; // (the pager's adoption of it, for tests) + // on the page (out of sight) while it waits: a detached player isn't loaded everywhere + v.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none'; + document.body.append(v); + early = { url, v, timer: setTimeout(dropEarly, 2500) }; + } + function dropEarly() { + if (!early) return; + clearTimeout(early.timer); + try { early.v.removeAttribute('src'); early.v.load(); early.v.remove(); } catch {} + early = null; + } + function takeEarly(url) { + if (!early || early.url !== url) return null; + const { v } = early; + clearTimeout(early.timer); + early = null; + return v; + } + // `fromFeed`: opened from a picture or clip in the feed (its pictures or // clips, in the feed's order), rather than from the media grid; `at`: where // the clip it opened on was, so it carries on from there @@ -14316,7 +14423,10 @@ export function messagesFeature(ctx) { // was tried: it pulled the whole file down.) On a connection to be // careful with the poster stays: the clip may never load (a heavy one // waits for a tap), and the still is what there is. - const media = video + // (the clip a grid tile was pressed on: the player already loading since the finger came down) + const adopted = video && !frugalData() ? takeEarly(url) : null; + if (adopted) { adopted.removeAttribute('style'); adopted.className = 'mp-media'; adopted.setAttribute('data-src', url); adopted.setAttribute('playsinline', ''); adopted.setAttribute('loop', ''); } + const media = adopted || (video ? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none', poster: frugalData() ? videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) : undefined }) : h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async', @@ -14328,7 +14438,7 @@ export function messagesFeature(ctx) { let host = ''; try { host = new URL(url).hostname; } catch {} m.closest('.mp-slide').classList.add('mp-gone'); m.insertAdjacentElement('afterend', h('div', { class: 'mp-missing' }, h('div', { class: 'mp-missing-ico' }, '🖼'), h('div', {}, t('mediaPicGone')), host ? h('div', { class: 'small muted' }, host) : null)); - } }); + } })); const caption = mediaCaption(ev, true); const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) }, @@ -14377,6 +14487,12 @@ export function messagesFeature(ctx) { // (the clip holds still meanwhile, and the time shows); the drag is the // bar's alone, never the pager's swipe or its tap. const clock = (t) => { t = Math.max(0, Math.floor(t || 0)); const m = Math.floor(t / 60), x = t % 60; return m + ':' + String(x).padStart(2, '0'); }; + // up for a few seconds, and on while a finger is on it + function revealSeek(bar) { + bar.classList.add('reveal'); + clearTimeout(bar._hide); + bar._hide = setTimeout(() => { if (bar._drag) revealSeek(bar); else bar.classList.remove('reveal'); }, 3500); + } function seekBar(v) { const fill = h('div', { class: 'mp-seek-fill' }); const time = h('span', { class: 'mp-seek-time' }); @@ -14407,6 +14523,7 @@ export function messagesFeature(ctx) { if (!d || d.id !== e.pointerId) return; bar._drag = null; bar.classList.remove('drag'); + if (bar.classList.contains('reveal')) revealSeek(bar); // a few seconds more after the drag if (e.type === 'pointerup') to(frac(e)); if (d.playing) v.play().catch(() => {}); }; @@ -14456,6 +14573,7 @@ export function messagesFeature(ctx) { const prevGroup = (g) => { let n = g && g.previousElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.previousElementSibling; return n; }; const up = prevGroup(slide.closest('.mp-group')), back = up && rowSlide(up); if (back) out.push(back); // (the one before: ready again for the swipe back) + out.back = back || null; return out; } function pagerAttach(slide) { @@ -14478,9 +14596,41 @@ export function messagesFeature(ctx) { im.src = full; }, 300); } + // A player's first play with sound waits on the phone's audio output to + // start: on Adam's Android ~250 ms of a still picture, every clip, warm + // or not (muted, 60 ms; a muted start then unmuted freezes the picture + // for those 250 ms instead). A player that has played with sound resumes + // in 20-80 ms, even many seconds later. So a warm player plays at a + // volume nobody hears until its picture moves (a frame or two in: the + // audio's start is the wait) and pauses there — not back to 0: a seek + // undoes it. Only while the pager has sound on, and the browser lets it. + // Having played, the player no longer stops reading at its first frame — + // it fetches the rest of the file — so only a clip known to be small + // enough to be worth that is primed (most are 3-25 MB). + const PRIME_MAX = 25e6; + function primeSound(s, v) { + if (!pagerLoud || v._primed || (navigator.userActivation && !navigator.userActivation.hasBeenActive)) return; + const d = videoInfo.get(v.getAttribute('data-src')) || {}; + if (!(d.size > 0 && d.size <= PRIME_MAX)) return; + const go = () => { + if (!pager || pager.cur === s || !s.isConnected || !v.getAttribute('src') || !v.paused || v._primed) return; + v._primed = true; + v.muted = false; v.volume = 0.001; + const done = () => { + clearTimeout(v._primeTimer); + if (pager && pager.cur !== s) { try { v.pause(); } catch {} } + v.volume = 1; + if (pager && pager.cur !== s) v.muted = true; + }; + v._primeTimer = setTimeout(done, 2000); + const f = () => { if (v.currentTime >= 0.03 || v.paused) done(); else v.requestVideoFrameCallback(f); }; + v.play().then(() => (v.requestVideoFrameCallback ? v.requestVideoFrameCallback(f) : setTimeout(done, 400)), () => { v._primed = false; done(); }); + }; + if (v.readyState >= 3) go(); else v.addEventListener('canplay', go, { once: true }); + } function pagerRelease(slide) { const m = slide.querySelector('video.mp-media'); - if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} } + if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); m._primed = false; } catch {} } } // Flipping through clips is asking to watch them: whatever they cost, the // one on screen plays at once (streaming the rest as it goes), and the @@ -14508,8 +14658,25 @@ export function messagesFeature(ctx) { if (j === i) return; const v = s.querySelector('video.mp-media'); if (eager && k >= 0) { + // the next clip and the one before: a player of its own, warm — once + // its opening is in the cache, given the file to read up to its + // first frame (preload 'metadata': Chrome reads ~2 s of it and + // stops) and paused there, so the swipe onto it is only play and + // sound (TikTok's trick). The one just left keeps the player it + // played in (a swipe back to it took 0.7 s on a phone, built anew). + // The others ahead get their opening only. + const warm = k === 0 || s === ahead.back; + if (warm && v.getAttribute('src')) return; // warm already pagerRelease(s); - headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5); + const head = headStart(playSrc(v.getAttribute('data-src')), k === 0 ? 1 : 0.5); + if (warm) head.then(() => { + const now = pager && pager.cur && pagerAhead(pager.cur); + if (!now || !s.isConnected || pager.cur === s || v.getAttribute('src') || (now[0] !== s && now.back !== s)) return; + v.preload = 'metadata'; + v.muted = true; + pagerAttach(s); + primeSound(s, v); + }); return; } if (k === 0) { @@ -14708,6 +14875,9 @@ export function messagesFeature(ctx) { else v.addEventListener('loadedmetadata', () => { try { v.currentTime = at; } catch {} }, { once: true }); } v.muted = !pagerLoud; + v.volume = 1; + clearTimeout(v._primeTimer); + clockStart(slide, v); v.play().catch((err) => { // sound needs a tap the browser counted: play quietly, the button says so if (err && err.name === 'NotAllowedError' && !v.muted) { @@ -14717,6 +14887,37 @@ export function messagesFeature(ctx) { } }); } + // How long from landing on a clip to its picture moving, kept for the + // last hundred (coinosPagerStarts() in the console, and dlog), with what + // shape the player was in when the swipe landed (warm: it had its frame). + const pagerStarts = []; + function clockStart(slide, v) { + const t0 = slide._arriveAt; + slide._arriveAt = 0; + if (!t0) return; + const url = v.getAttribute('data-src') || '', warm = v.readyState >= 2, rs = v.readyState; + const primed = !!v._primed; + const buf = v.buffered && v.buffered.length ? +v.buffered.end(v.buffered.length - 1).toFixed(1) : 0; + const d = videoInfo.get(url) || {}; + let host = ''; try { host = new URL(url).hostname; } catch {} + const done = () => { + const ms = Math.round(performance.now() - t0); + pagerStarts.push({ ms, warm, primed, rs, buf, host, mb: d.size ? +(d.size / 1e6).toFixed(1) : null, faststart: d.faststart ?? null, src: (v.getAttribute('src') || '').startsWith(VMEDIA) ? 'copy' : 'original', url }); + if (pagerStarts.length > 100) pagerStarts.shift(); + dlog('pager start', ms + ' ms', warm ? 'warm' : 'cold', host); + }; + const tick = () => { + if (!v.isConnected || performance.now() - t0 > 20000) return; + if (v.currentTime > 0 && !v.paused) { done(); return; } + if (v.requestVideoFrameCallback) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick); + }; + tick(); + } + if (typeof window !== 'undefined') window.coinosPagerStarts = () => { + const ms = pagerStarts.map((x) => x.ms).sort((a, b) => a - b); + const q = (f) => ms.length ? ms[Math.min(ms.length - 1, Math.floor(f * ms.length))] : null; + return { n: ms.length, median: q(0.5), p90: q(0.9), max: ms.at(-1) ?? null, warm: pagerStarts.filter((x) => x.warm).length, starts: pagerStarts.slice() }; + }; function pagerSound(on) { pagerLoud = on; if (!pager) return; @@ -15051,7 +15252,7 @@ export function messagesFeature(ctx) { pendingTap = setTimeout(() => { pendingTap = 0; const btn = d.target.closest && d.target.closest('button, a'); - if (btn) btn.click(); else pagerTap(d.target); // (the click here is before tapDoneAt: not swallowed) + if (btn) btn.click(); else pagerTap(d.target, t.clientY); // (the click here is before tapDoneAt: not swallowed) tapDoneAt = Date.now(); }, 120); }, { passive: true }); @@ -15066,11 +15267,16 @@ export function messagesFeature(ctx) { if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; } else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); } }, true); - el.addEventListener('click', (e) => pagerTap(e.target)); + el.addEventListener('click', (e) => pagerTap(e.target, e.clientY)); // a tap shows (or hides) who posted it and what they said - function pagerTap(target) { + function pagerTap(target, y) { if (target.closest('button, a, .mp-caption, .mp-seek')) return; if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it + // The seek bar is out of sight while a clip plays (Adam, 2026-10-19: + // only when someone goes for it, or pauses): a tap near the foot of + // the screen, where it sits, brings it up without pausing. + const bar = pager && video && pager.cur && pager.cur.querySelector('.mp-seek.ready'); + if (bar && y != null && !el.classList.contains('show') && y >= bar.getBoundingClientRect().top - 72) { revealSeek(bar); return; } if (pager && video) { // A clip (from the feed or the media grid): a tap anywhere but the // seek bar and the buttons pauses it and brings up who posted it, @@ -15125,6 +15331,7 @@ export function messagesFeature(ctx) { pager.cur = slide; pager.anchor = slide; pager.curAt = Date.now(); + slide._arriveAt = performance.now(); pagerSharpen(slide); pagerArrows(slide); pagerPosition(); diff --git a/src/features/zaps.js b/src/features/zaps.js index b501c76..76296cf 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -196,6 +196,16 @@ export function zapsFeature(ctx) { render(); } + // A zap request is signed as the person zapping: the login identity when + // there is one (the face their chat line and the streamer's overlay + // show), else the wallet's own key. It used to be the wallet key always, + // and a signed-in person's zaps showed under a stranger's npub. + async function zapSigner() { + const id = hook('nostrLoginIdentity'); + const signer = id && (id.signer || await Promise.resolve(hook('nostrLoginResume')).catch(() => null)); + return signer ? (e) => signer.signEvent(e) : (e) => wallet.nostrSign(e); + } + // One-tap zap at the user's default amount: resolve, invoice, pay through // the ark seam (its quote + retries included), and report by toast — the // whole flow without a single screen. @@ -214,10 +224,11 @@ export function zapsFeature(ctx) { } let zapRequest = null; if (p.allowsNostr && t2.pk && wallet.nostrSign) { - zapRequest = buildZapRequest({ + const sign = await zapSigner(); + zapRequest = await buildZapRequest({ amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: t2.lnurlBech32, recipientPk: t2.pk, eventId: t2.eventId || null, moment: t2.moment, comment: '', - signFn: (partial) => wallet.nostrSign(partial), + signFn: sign, }); } const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: t2.lnurlBech32 }); @@ -272,10 +283,11 @@ export function zapsFeature(ctx) { let zapRequest = null; const canZap = p.allowsNostr && z.target.pk && wallet.nostrSign; if (canZap) { - zapRequest = buildZapRequest({ + const sign = await zapSigner(); + zapRequest = await buildZapRequest({ amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: z.target.lnurlBech32, recipientPk: z.target.pk, eventId: z.target.eventId || null, moment: z.target.moment, comment: z.comment, - signFn: (partial) => wallet.nostrSign(partial), + signFn: sign, }); } const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment }); |
