diff options
| -rw-r--r-- | src/app.js | 62 | ||||
| -rw-r--r-- | src/features/messages.js | 669 | ||||
| -rw-r--r-- | src/features/zaps.js | 138 | ||||
| -rw-r--r-- | src/i18n.js | 43 | ||||
| -rw-r--r-- | src/lnurl.js | 79 | ||||
| -rw-r--r-- | src/marmot-client.js | 74 | ||||
| -rw-r--r-- | src/style.css | 46 | ||||
| -rw-r--r-- | tools/feed-picture-pager-test.js | 6 | ||||
| -rw-r--r-- | tools/feed-wheel-reverse-test.js | 41 | ||||
| -rw-r--r-- | tools/lnurl-auth-test.js | 102 | ||||
| -rw-r--r-- | tools/lnurl-auth-unit-test.js | 63 | ||||
| -rw-r--r-- | tools/lnurl-withdraw-test.js | 103 | ||||
| -rw-r--r-- | tools/pager-controls-test.js | 114 | ||||
| -rw-r--r-- | tools/pager-gone-picture-test.js | 77 | ||||
| -rw-r--r-- | tools/pager-loading-test.js | 82 | ||||
| -rw-r--r-- | tools/pager-resize-test.js | 134 | ||||
| -rw-r--r-- | tools/pager-sharpen-test.js | 78 | ||||
| -rw-r--r-- | tools/pager-swipe-fly-test.js | 124 | ||||
| -rw-r--r-- | tools/pager-wheel-test.js | 193 | ||||
| -rw-r--r-- | tools/profile-pager-test.js | 135 | ||||
| -rw-r--r-- | tools/thread-pager-test.js | 134 | ||||
| -rw-r--r-- | tools/wn-only-device-test.js | 129 |
22 files changed, 2399 insertions, 227 deletions
@@ -695,9 +695,16 @@ function attachZoom(box, img, { wheel = true } = {}) { if (e.target.closest('.lightbox-x')) return; if (moved) { moved = false; e.stopImmediatePropagation(); e.preventDefault(); return; } }, true); + // The wheel. true: a turn zooms (the lightbox, a picture on its own). + // 'ctrl': ctrl+wheel zooms — a trackpad's pinch arrives in Chrome as + // exactly that — and a plain wheel pans the picture while it is zoomed; + // at 1× a plain wheel is left to whoever owns the box (the pager pages). if (wheel) box.addEventListener('wheel', (e) => { + const zoom = wheel !== 'ctrl' || e.ctrlKey || e.metaKey; + if (!zoom && s <= 1) return; e.preventDefault(); - zoomAt(s * Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY); + if (zoom) zoomAt(s * Math.exp(-e.deltaY * (wheel === 'ctrl' ? 0.004 : 0.002)), e.clientX, e.clientY); + else { x -= e.deltaX; y -= e.deltaY; } clamp(); apply(false); }, { passive: false }); } @@ -6646,63 +6653,56 @@ if (INTENT_URI) { if (!raf && v) { last = performance.now(); raf = requestAnimationFrame(tick); } }; // ---- the wheel - // A turn back against the coast is a reader who flew past the post they - // wanted and is hunting for it: the page is caught at once, and that notch - // and the next few are short, quick steps (a quarter of a notch's travel, - // settling fast) rather than a fresh flight the other way. Each notch on - // in the same direction grows the step back towards a full one, so a few - // flicks have the wheel flying again; a turn back while hunting starts the - // small steps over, and a pause ends the hunt — the next notch is the - // plain push from rest it has always been. - const HUNT = [0.25, 0.35, 0.5, 0.7, 0.85]; // of a notch's travel, notch by notch - const HUNT_TAU = 110; // ms: a hunting step settles quickly - const HUNT_IDLE = 2000; // ms without a notch, and the hunt is over - let hunt = -1, huntDir = 0, huntAt = 0; // the next HUNT entry while hunting - const halt = () => { stop(); hunt = -1; }; + // A turn back against the coast catches the page at once and is a plain + // notch from rest — no flight the other way, since the next couple of + // notches after a reversal (CALM) don't wind the speed up, so a couple of + // correcting notches travel a couple of notches; a spin that keeps going + // winds up again from the third. (A spell of quarter-size "hunting" + // notches ramping back up was tried here and felt like a stutter and a + // slow patch; a 700 ms spell without wind-up after a reversal was tried + // too and a spin begun inside it felt slow — Adam, 2026-10-16.) + const CALM = 2; // notches after a reversal without wind-up + let calmLeft = 0; // (lastDir/lastMoveAt: the tick's, above) let smoothUntil = 0; // a trackpad's events were just seen: hands off window.addEventListener('wheel', (e) => { if (e.ctrlKey || e.shiftKey || e.defaultPrevented) return; const notch = e.deltaMode === 1 || (e.wheelDeltaY && e.wheelDeltaY % 120 === 0 && !e.deltaX); - if (!notch) { smoothUntil = e.timeStamp + 400; halt(); return; } + if (!notch) { smoothUntil = e.timeStamp + 400; stop(); return; } if (e.timeStamp < smoothUntil || matchMedia('(pointer: coarse)').matches || !inScope(e.target)) return; e.preventDefault(); const px = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY; const dir = Math.sign(px); if (!dir) return; const now = e.timeStamp; - if (hunt >= 0 && now - huntAt > HUNT_IDLE) hunt = -1; - // the way the page is going: its coast, the coast that just died, or the hunt - const going = v ? Math.sign(v) : now - lastMoveAt < 400 ? lastDir : hunt >= 0 ? huntDir : 0; - if (going && dir !== going) { stop(); hunt = 0; huntDir = dir; } - else if (hunt >= 0 && ++hunt >= HUNT.length) hunt = -1; - if (hunt >= 0) { + // the way the page is going: its coast, or the coast that only just died + const going = v ? Math.sign(v) : now - lastMoveAt < 400 ? lastDir : 0; + if (going && dir !== going) { // A reversal catches the old coast immediately. Discard its fractional // step and frame clock too: after a busy frame, applying the NEW velocity // to the OLD elapsed time made the first reverse step jump. Give the - // notch a small response now, then a quick coast over the rest of its - // (short) travel; quick flicks still add up. - huntAt = now; - const travel = px * 3 * HUNT[hunt]; - const first = dir * Math.min(16, Math.abs(travel / HUNT_TAU) * 16); + // notch a small response now, then coast the rest of its travel. + stop(); + calmLeft = CALM; + const first = dir * Math.min(16, Math.abs(px * 3 / TAU) * 16); window.scrollBy({ top: first, behavior: 'instant' }); lastDir = dir; lastMoveAt = now; - coast(v + (travel - first) / HUNT_TAU, HUNT_TAU); + coast((px * 3 - first) / TAU, TAU); } else { // A notch into a page already moving pushes harder: the faster it // goes, the more each turn adds (up to 2.5x), so keeping the wheel // turning winds the speed up instead of settling at a stroll. From // rest a notch is the same push as ever. - const gain = 1 + Math.min(1.5, Math.abs(v) / 4); + const gain = calmLeft-- > 0 ? 1 : 1 + Math.min(1.5, Math.abs(v) / 4); coast(v + (px * 3 * gain) / TAU, TAU); } }, { passive: false }); // anything else that moves the page takes over from a coast - window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) halt(); }); - window.addEventListener('touchstart', halt, { passive: true }); + window.addEventListener('keydown', (e) => { if (/^(Arrow|Page|Home|End| )/.test(e.key)) stop(); }); + window.addEventListener('touchstart', stop, { passive: true }); // ---- grab and flick let drag = null; document.addEventListener('pointerdown', (e) => { - if (raf) halt(); // a click catches a coasting page, like a finger does + if (raf) stop(); // a click catches a coasting page, like a finger does if (e.pointerType !== 'mouse' || e.button !== 0 || !inScope(e.target)) return; drag = { x: e.clientX, y: e.clientY, ly: e.clientY, on: false, pts: [] }; }); diff --git a/src/features/messages.js b/src/features/messages.js index a8815b0..20cfaa6 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -169,6 +169,12 @@ export function messagesFeature(ctx) { if (now) write(); else emojiSaveTimer = setTimeout(write, 500); }; // code → image url: held packs first (in order), then what's been seen + // White Noise's own two emoji, which it sends as bare :codes: (no NIP-30 + // tag, no picture): drawn from its published artwork, through our picture + // proxy — a copy served and cached here, as any picture in a post is, not + // bundled (the app is AGPL, this one MIT). + const WN_RAW = 'https://raw.githubusercontent.com/marmot-protocol/whitenoise-android/master/app/src/main/res/drawable-nodpi/'; + const BUILTIN_EMOJI = { marmot: WN_RAW + 'builtin_emoji_marmot.png', wn: WN_RAW + 'builtin_emoji_wn.png' }; const emojiUrl = (code) => { const s = emojiSt(); for (const addr of s.order) { @@ -176,7 +182,7 @@ export function messagesFeature(ctx) { if (!p) continue; for (const [c, u] of p.emojis) if (c === code) return u; } - return s.learned[code] || null; + return s.learned[code] || (BUILTIN_EMOJI[code] ? serverPic(BUILTIN_EMOJI[code], 'tile') : null); }; // everything typeable: [{ code, url, pack }], packs first, newest-seen next function customEmojis() { @@ -3416,6 +3422,7 @@ export function messagesFeature(ctx) { function wnIntoThread(g, peer, m) { const th = threadOf(peer); const target = (m.tags.find((x) => x[0] === 'e') || [])[1]; + if (m.kind === 7 || m.kind === 9) noteEmoji(m); // a :code: with its picture (NIP-30 tag): learned, so the chip draws it if (m.kind === 9) { if (!th.has(m.id) || th.get(m.id).pending) th.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id }); } else if (m.kind === 7 && target) { @@ -3587,6 +3594,7 @@ export function messagesFeature(ctx) { for (const m of sorted) if (m.kind === 9 || m.kind === 1210) byId.set(m.id, { rumor: m, mine: isMe(m.pubkey), wn: g.id }); for (const m of sorted) { const target = (m.tags.find((x) => x[0] === 'e') || [])[1]; + if (m.kind === 7 || m.kind === 9) noteEmoji(m); // (a group's :marmot: came as text: its tag was never read) if (m.kind === 7 && target) { (reacts.get(target) || reacts.set(target, new Map()).get(target)).set(m.pubkey, m.content); reactOf.set(m.id, { target, pk: m.pubkey }); @@ -9579,6 +9587,43 @@ export function messagesFeature(ctx) { } // Settings → Nostr: whether clips over the budget play by themselves. + // Settings → Nostr: which device of yours gets group invitations. A Marmot + // inviter (White Noise) picks one of your published keys; the group lands + // on that device alone, and nothing told you which (Adam's invitation sat + // on the phone while he looked on the laptop). + function groupDevicesCard() { + const pk = identityPk(); + let d = ui.wnDevices; + if (!d || d.pk !== pk) { + d = ui.wnDevices = { pk, loading: true }; + whiteNoise().then((c) => (c ? c.devices() : null)).then((r) => { + if (!ui.wnDevices || ui.wnDevices.pk !== pk) return; + ui.wnDevices = r ? { pk, ...r } : { pk, none: true }; + render(); + }).catch(() => { if (ui.wnDevices && ui.wnDevices.pk === pk) { ui.wnDevices = { pk, none: true }; render(); } }); + } + const busy = !!ui.wnOnlyHere; + const state = d.loading ? null : d.none ? t('wnDeviceNone') : d.retired ? t('wnDeviceRetired') : d.published ? t('wnDeviceHere') : t('wnDeviceNone'); + return h('div', { class: 'card col wn-devices-card', style: 'gap:8px' }, + h('h3', {}, t('wnDevicesTitle')), + h('div', { class: 'small faint' }, t('wnDevicesHelp')), + state ? h('div', { class: 'small wn-device-state' }, state) : h('div', { class: 'row gap6' }, h('span', { class: 'spinner sm' })), + !d.loading && d.others ? (d.others.length + ? h('div', { class: 'col small', style: 'gap:2px' }, h('div', {}, t('wnDevicesOthers', { n: d.others.length })), + ...d.others.map((o) => h('div', { class: 'faint wn-device' }, (o.client || t('wnDeviceUnknown')) + ' \u00b7 ' + timeLabel(o.created_at * 1000)))) + : h('div', { class: 'small faint wn-no-others' }, t('wnDevicesNoOthers'))) : null, + h('button', { + class: 'btn-sm wn-only-here', type: 'button', disabled: busy || d.loading || undefined, + onClick: () => { + ui.wnOnlyHere = true; render(); + whiteNoise().then((c) => (c ? c.onlyHere({ interactive: true }) : Promise.reject(new Error('no client')))) + .then((n) => { toast(t('wnOnlyHereDone', { n })); ui.wnDevices = null; }) + .catch(() => toast(t('wnOnlyHereFail'))) + .finally(() => { ui.wnOnlyHere = false; render(); }); + }, + }, t('wnOnlyHere'))); + } + function videosCard() { const on = largeVideosOk(); return h('div', { class: 'card col videos-card', style: 'gap:8px' }, @@ -9888,12 +9933,15 @@ export function messagesFeature(ctx) { if (!ui.reactPick) return null; const ev = ui.reactPick; const close = () => { ui.reactPick = null; render(); }; + // over the media pager (a reaction from a slide): above it, and its + // like button hears the pick + const after = () => { if (pager && pager.cur) pagerCounts(pager.cur); }; return h('div', { - class: 'confirm-pop-backdrop', + class: 'confirm-pop-backdrop' + (pager ? ' over-pager' : ''), onClick: (e) => { if (e.target === e.currentTarget) close(); }, }, h('div', { class: 'card col msg-sheet' }, - reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}); }), + reactRow(null, (e2) => { close(); reactTo(ev, e2).catch(() => {}).finally(after); setTimeout(after, 50); }), h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back')))); } @@ -10329,9 +10377,41 @@ export function messagesFeature(ctx) { return evt; } + // The thread's replies by parent, each note's answers in the order the + // page draws them (yours first, then oldest first); an orphan hangs off + // the root. + function threadTree(c) { + const parentOf = (ev) => { + // a comment's parent is its lowercase e; one answering the root by + // address (an article) has only an a — the root + if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId; + const es = ev.tags.filter((x) => x[0] === 'e'); + const marked = es.find((x) => x[3] === 'reply'); + return (marked || es[es.length - 1] || [])[1] || c.rootId; + }; + const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]); + const children = new Map(); + for (const ev of c.replies) { + const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId; + if (!children.has(pid)) children.set(pid, []); + children.get(pid).push(ev); + } + for (const list of children.values()) list.sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at)); + return children; + } + // The thread's posts in the order the page shows them: the root, then + // the replies down the tree (a hidden one skipped, its answers kept). + function threadOrder(c) { + const children = threadTree(c), out = []; + if (c.root) out.push(c.root); + const walk = (pid) => { for (const ev of children.get(pid) || []) { if (!hidden(ev)) out.push(ev); walk(ev.id); } }; + walk(c.rootId); + return out; + } function threadScreen() { const s = ui.noteThread; const c = threadFor(s.seed); + queueMicrotask(syncMediaPager); // a picture of the thread's opened full screen: the pager over its pictures // the conversation on screen is the best count of its own replies for (const e of c.replies) noteReply(e); const boxUnder = (ev) => !!(s.replying || (s.draft || '').trim()) && (ev.id === s.focusId || (!s.focusId && c.root && ev.id === c.root.id)); @@ -10470,23 +10550,9 @@ export function messagesFeature(ctx) { // under it, a little indented, oldest first — except your own, which go // first among a note's answers, so what you just wrote shows right // under the post instead of at the bottom of a long thread. - const parentOf = (ev) => { - // a comment's parent is its lowercase e; one answering the root by - // address (an article) has only an a — the root - if (ev.kind === COMMENT) return tagVal(ev, 'e') || c.rootId; - const es = ev.tags.filter((x) => x[0] === 'e'); - const marked = es.find((x) => x[3] === 'reply'); - return (marked || es[es.length - 1] || [])[1] || c.rootId; - }; - const ids = new Set([c.rootId, ...c.replies.map((e) => e.id)]); - const children = new Map(); - for (const ev of c.replies) { - const pid = ids.has(parentOf(ev)) ? parentOf(ev) : c.rootId; // an orphan hangs off the root - if (!children.has(pid)) children.set(pid, []); - children.get(pid).push(ev); - } + const children = threadTree(c); const walk = (pid, depth) => { - const list = (children.get(pid) || []).sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at)); + const list = children.get(pid) || []; for (const ev of list) { if (hidden(ev)) { walk(ev.id, depth); continue; } // its answers keep their place const indent = Math.min(depth, 3) * 14; @@ -10614,6 +10680,7 @@ export function messagesFeature(ctx) { function profileScreen() { const pk = ui.profilePk; + queueMicrotask(syncMediaPager); // a picture of theirs opened full screen: the pager over their timeline const mine = isMe(pk) || (ctx.shownPubkey && pk === ctx.shownPubkey()); // History can outlive an account switch; only restore our own editor. if (!mine && ui.profEdit) { ui.profEdit = null; ui.profEditFilled = false; } @@ -11472,7 +11539,7 @@ export function messagesFeature(ctx) { // time, no waiting on avatars, pictures and link cards (the grid shows // none of that), and the feed's own page of posts left as it was. let mediaCrawling = null; - function crawlMedia(c = feedNow()) { + function crawlMedia(c = feedNow(), kind = mediaMode === 'videos' ? 'videos' : 'images') { const def = feedDef(c.id); if (!def || !feedHasQuery(def)) return Promise.resolve(); if (def.curated) return loadOlderFeed(); // a curated feed pages its own way @@ -11482,7 +11549,6 @@ export function messagesFeature(ctx) { scheduleRepaint(); // Clips are rare (one post in a hundred, on a busy relay), so a crawl // keeps reading pages until it has found a few, or read several. - const kind = mediaMode === 'videos' ? 'videos' : 'images'; const had = r[kind].size; for (let pass = 0; pass < 5 && r[kind].size - had < 6; pass++) { const from = [r.until, c.notes.at(-1)?.created_at].filter(Boolean); @@ -11845,16 +11911,59 @@ export function messagesFeature(ctx) { const inFeed = ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread && feed && !feed.stopped && feed.winList; return inFeed && el && el.closest ? el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null : null; } + // the page the row is on: the feed, a person's timeline (their profile + // page) or a thread — the pager runs over that page's pictures (or clips) + const onProfilePage = () => !!ui.profilePk && (!ui.noteThread || ui.profOverThread); + const onThreadPage = () => !!ui.noteThread && !onProfilePage(); + // the thread on screen, by its root — the key a thread pager is tied to + const threadPageId = () => (onThreadPage() && (threadCache.get(ui.noteThread.rootId) || threadCache.get(rootIdOf(ui.noteThread.seed)))?.rootId) || null; + // a thread as a list the pager can read (feedMediaItems), in the page's order + const threadNotes = (c) => ({ notes: threadOrder(c), shown: c.replies.length + 1 }); + function pageRowOf(el) { + if (!el || !el.closest) return null; + if (onThreadPage()) { + const rowId = el.closest('.thread-page [data-zap-post]')?.getAttribute('data-zap-post') || null; + const tid = threadPageId(); + return rowId && tid ? { c: threadNotes(threadCache.get(tid)), rowId, profile: null, thread: tid } : null; + } + const rowId = el.closest('.notes-feed > [data-key]')?.getAttribute('data-key') || null; + if (!rowId) return null; + if (onProfilePage()) { const c = notesCache.get(ui.profilePk); return c && c.winList ? { c, rowId, profile: ui.profilePk } : null; } + return feedRowOf(el) ? { c: feed, rowId, profile: null } : null; + } + // Where a pager opened from a page gets its pictures: the feed on screen, + // or a person's timeline — its posts' pictures in its order, more fetched + // as the reader nears the end. The media grid's pager has no source here. + function pagerSource(p = pager) { + if (!p) return null; + if (p.thread) { + // a thread's pictures: the root's and its replies', as the page lists + // them — all here already, nothing more to fetch + const c = threadCache.get(p.thread); + return c ? { c: threadNotes(c), thread: p.thread, items: (kind) => feedMediaItems(threadNotes(c), kind), more: async () => {}, done: () => true, + live: () => threadPageId() === p.thread } : null; + } + if (p.profile) { + const c = notesCache.get(p.profile); + return c ? { c, profile: p.profile, items: (kind) => feedMediaItems(c, kind), more: () => loadOlderNotes(p.profile), done: () => !!c.end && !c.loadingMore, + live: () => onProfilePage() && ui.profilePk === p.profile } : null; // (the entry is replaced by a refetch: the page, not the object, says it is still there) + } + if (p.fromFeed) { const c = feedNow(); return { c, items: (kind) => feedMediaItems(c, kind), more: () => loadOlderFeed(), done: () => !!c.end && c.shown >= c.notes.length && !c.loadingMore, live: () => c === feed && !onProfilePage() }; } + return null; + } // A picture tapped in the feed opens in the media pager, on that picture, // with the feed's other pictures a swipe (or an arrow) away, the way the - // media grid's are. On any other page — a thread, a profile, a chat — the - // picture opens alone, as before. + // media grid's are; on a profile page over that person's pictures, and on + // a thread over the thread's (Adam, 2026-10-16: the same viewer wherever + // a picture is opened from — a thread's opened alone and a tap on it + // closed it, with no caption or controls). Elsewhere — a chat, a face — + // the picture opens alone. function openPicture(url, e) { try { - const rowId = feedRowOf(e && e.target); - if (rowId) { - const it = feedMediaItems(feed, 'images').find((x) => x.row.id === rowId && x.url === url); - if (it) { openMediaPager('images', mediaKey(it), { fromFeed: true }); return; } + const at = pageRowOf(e && e.target); + if (at) { + const it = feedMediaItems(at.c, 'images').find((x) => (x.row.id === at.rowId || x.ev.id === at.rowId) && x.url === url); + if (it) { openMediaPager('images', mediaKey(it), { fromFeed: !at.profile && !at.thread, profile: at.profile, thread: at.thread || null }); return; } } } catch {} ctx.openImage && ctx.openImage(url); @@ -11866,14 +11975,14 @@ export function messagesFeature(ctx) { // himself — so it follows the phone throughout. function openClip(v, url) { try { - const rowId = feedRowOf(v); - if (!rowId) return false; - const it = feedMediaItems(feed, 'videos').find((x) => x.row.id === rowId && x.url === url); + const on = pageRowOf(v); + if (!on) return false; + const it = feedMediaItems(on.c, 'videos').find((x) => (x.row.id === on.rowId || x.ev.id === on.rowId) && x.url === url); if (!it) return false; tappedClips.add(url); // asked for by hand: no tap-to-play cover const at = v.currentTime || 0; v.pause(); - openMediaPager('videos', mediaKey(it), { fromFeed: true, at }); + openMediaPager('videos', mediaKey(it), { fromFeed: !on.profile && !on.thread, profile: on.profile, thread: on.thread || null, at }); pagerFullscreen(); return true; } catch { return false; } @@ -12031,13 +12140,13 @@ export function messagesFeature(ctx) { // `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 - function openMediaPager(kind, start, { fromFeed = false, at = 0 } = {}) { + function openMediaPager(kind, start, { fromFeed = false, profile = null, thread = null, at = 0 } = {}) { // the page's own players stop: the pager's is the one to hear for (const v of document.querySelectorAll('video')) { try { v.pause(); } catch {} } for (const f of document.querySelectorAll('.yt-embed[data-playing] iframe')) { try { f.contentWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); f.closest('.yt-embed')._pausedByUs = true; } catch {} } - ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, at }; + ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, thread, at }; render(); } const closeMediaPager = () => ctx.closeLayer('mediaPager'); @@ -12047,17 +12156,26 @@ export function messagesFeature(ctx) { // the feed on the post of the picture last looked at; otherwise (the // media grid's pager, or nothing moved) it simply closes. function dismissPager() { - if (pager && pager.fromFeed && !pagerOnStart() && pager.cur) closePagerTo(pager.cur._rowId); + if (pager && pagerSource() && !pagerOnStart() && pager.cur) closePagerTo(pager.cur._rowId); else closeMediaPager(); } function closePagerTo(rowId) { - const c = feedNow(); + const src = pagerSource(); closeMediaPager(); // the pop that closes it lands on a later turn: the glide waits for the feed let tries = 0; const go = () => { + if (src.thread) { + // a thread's post is drawn already (no window): just bring it on screen if it isn't + if (ui.mediaPager) { if (++tries < 90) requestAnimationFrame(go); return; } + if (!src.live()) return; + const el = document.querySelector('.thread-page [data-zap-post="' + CSS.escape(rowId) + '"]'); + const r = el && el.getBoundingClientRect(); + if (r && (r.top < 0 || r.bottom > window.innerHeight)) window.scrollTo(0, Math.max(0, window.scrollY + r.top - 8)); + return; + } if (ui.mediaPager || !document.querySelector('.notes-feed')) { if (++tries < 90) requestAnimationFrame(go); return; } - landOnPost(c, rowId); + landOnPost(src, rowId); }; requestAnimationFrame(go); } @@ -12068,12 +12186,14 @@ export function messagesFeature(ctx) { // draw, and set the row under the top again each frame — until it has sat // still a moment, a couple of seconds have gone, or the reader touches the // page. A jump, not a glide: the pager was covering the feed. - function landOnPost(c, rowId) { + function landOnPost(src, rowId) { const MARGIN = 8, t0 = Date.now(); const find = () => document.querySelector('.notes-feed > [data-key="' + CSS.escape(rowId) + '"]'); let still = 0, misses = 0, lastY = null; const step = () => { - if (c !== feed || ui.mediaPager || !document.querySelector('.notes-feed') || lastInputAt > t0 || Date.now() - t0 > 2500) return; + if (!src.live() || ui.mediaPager || !document.querySelector('.notes-feed') || lastInputAt > t0 || Date.now() - t0 > 2500) return; + // the list as it is now (a profile's entry is replaced by a refetch) + const c = src.profile ? notesCache.get(src.profile) || src.c : src.c; // the page sent somewhere else meanwhile (not by a finger: a glide, a link) if (lastY !== null && Math.abs(window.scrollY - lastY) > window.innerHeight / 2) return; const el = find(); @@ -12106,16 +12226,19 @@ export function messagesFeature(ctx) { let pager = null; // the live pager: { el, scroller, kind, keys, cur, loud, io, near, tick } let pagerLoud = true; // sound on, unless the browser said no without a tap function syncMediaPager() { - const want = ui.mediaPager && ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread ? ui.mediaPager : null; + const m = ui.mediaPager; + const want = m && (m.thread ? threadPageId() === m.thread + : m.profile ? onProfilePage() && ui.profilePk === m.profile && notesCache.has(m.profile) + : ui.chatOpen && ui.msgView === 'feed' && !ui.profilePk && !ui.noteThread) ? m : null; if (!want) { if (pager) dropPager(); return; } - if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed)) dropPager(); + if (pager && (pager.kind !== want.kind || pager.fromFeed !== !!want.fromFeed || pager.profile !== (want.profile || null) || pager.thread !== (want.thread || null))) dropPager(); if (!pager) buildPager(want); else growPager(); } function dropPager() { const p = pager; pager = null; - try { p.io.disconnect(); p.near.disconnect(); p.tailIo.disconnect(); } catch {} + try { p.io.disconnect(); p.near.disconnect(); } catch {} clearInterval(p.tick); cancelAnimationFrame(p.raf); window.removeEventListener('resize', p.onResize); @@ -12131,8 +12254,10 @@ export function messagesFeature(ctx) { const zp = pendingOf(ev.id); const sats = s.sats + (zp ? zp.sats : 0); const like = slide.querySelector('.mp-like'), zap = slide.querySelector('.mp-zap'); - like.classList.toggle('on', !!myReactOn(ev.id)); - like.querySelector('.i').innerHTML = I_HEART(!!myReactOn(ev.id)); + const mine = myReactOn(ev.id); + like.classList.toggle('on', !!mine); + if (mine) like.querySelector('.i').replaceChildren(postReactNode(mine)); // the emoji chosen, a picture for a :code: + else like.querySelector('.i').innerHTML = I_HEART(false); like.querySelector('.n').textContent = s.likes ? String(s.likes) : ''; zap.classList.toggle('on', sats > 0); const replies = replyCount(ev.id); @@ -12142,10 +12267,27 @@ export function messagesFeature(ctx) { function pagerSlide(it) { const { ev, url } = it; const video = pager.kind === 'videos'; + // A picture flies in as the feed's copy of it (1100px wide, decoded + // already if the feed painted it; else the server's, cached at the CDN) + // and is sharpened to the original once it comes to rest (pagerSharpen). + // Originals as they passed — camera files, several megapixels each — + // decoded on the main thread and were uploaded as textures mid-flight, + // and the flight stuttered (Adam, 2026-10-16: "a bit of choppiness as + // they scroll"). A copy that fails falls back to the original. + const quick = !video && !/\.gif(\?|$)/i.test(url) ? (mediaReady.get(url) || {}).thumb || serverPic(url, 'col') || url : url; const media = video ? h('video', { class: 'mp-media', 'data-src': url, loop: true, playsinline: true, preload: 'none', poster: videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) }) - : h('img', { class: 'mp-media', 'data-src': url, alt: '', draggable: 'false' }); + : h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async', + onError: (e) => { + const m = e.currentTarget; + if ((m.getAttribute('src') || '') !== url) { m.setAttribute('data-src', url); m.setAttribute('src', url); return; } + // the original is gone too (its host down, the file deleted): say + // so, in place of the browser's broken-picture glyph + 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); const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) }, @@ -12156,10 +12298,12 @@ export function messagesFeature(ctx) { avatar(ev.pubkey, 'chat-avatar mini', false), h('span', {}, displayName(ev.pubkey))), caption ? h('div', { class: 'mp-caption', onClick: stop((e) => e.currentTarget.classList.toggle('open')) }, caption) : null), h('div', { class: 'mp-counts' }, + // as in the feed: a tap picks how you feel about it (the emoji + // sheet, over the pager), a tap again takes it back h('button', { class: 'mp-like', type: 'button', 'aria-label': t('postLike'), onClick: stop(() => { if (signinAsk()) { leavePager(); render(); return; } - (myReactOn(ev.id) ? unreact(ev) : reactTo(ev, '❤️')).catch(() => {}).finally(() => pagerCounts(slide)); - setTimeout(() => pagerCounts(slide), 50); + if (myReactOn(ev.id)) { unreact(ev).catch(() => {}).finally(() => pagerCounts(slide)); setTimeout(() => pagerCounts(slide), 50); return; } + ui.reactPick = ev; render(); }) }, h('span', { class: 'i', html: I_HEART(false) }), h('span', { class: 'n' })), // the post itself, as the feed would open it: the whole text, the replies h('button', { class: 'mp-post', type: 'button', 'aria-label': t('mediaOpenPost'), title: t('mediaOpenPost'), onClick: stop(() => { @@ -12177,7 +12321,7 @@ export function messagesFeature(ctx) { slide._ev = ev; slide._rowId = it.row ? it.row.id : ev.id; // the feed row to land on (a repost's wrapper, not its inner post) // pinch to zoom a picture (drag pans it zoomed; the wheel is the pager's) - if (!video && ctx.attachZoom) ctx.attachZoom(slide, media, { wheel: false }); + if (!video && ctx.attachZoom) ctx.attachZoom(slide, media, { wheel: 'ctrl' }); // ctrl+wheel (a pinch) zooms; the plain wheel is the pager's if (video) { guardPicture(media, url); media.muted = !pagerLoud; @@ -12266,6 +12410,22 @@ export function messagesFeature(ctx) { const m = slide.querySelector('.mp-media'); if (m && !m.getAttribute('src')) m.setAttribute('src', m.tagName === 'VIDEO' ? playSrc(m.getAttribute('data-src')) : m.getAttribute('data-src')); } + // The slide at rest gets its picture's original — fetched and decoded + // aside first, then swapped in (same box, same aspect: nothing moves) — + // a moment after landing, so a quick page past it costs nothing. + function pagerSharpen(slide) { + const m = slide && slide.querySelector('img.mp-media'); + const full = m && m.getAttribute('data-full'); + if (!full || m.getAttribute('src') === full || m._sharp) return; + m._sharp = setTimeout(() => { + m._sharp = 0; + if (!pager || pager.cur !== slide) return; + const im = new Image(); + im.decoding = 'async'; + im.onload = () => { const go = () => { if (pager && m.isConnected && m.getAttribute('src') !== full) { m.setAttribute('data-src', full); m.setAttribute('src', full); } }; (im.decode ? im.decode().then(go, go) : go()); }; + im.src = full; + }, 300); + } function pagerRelease(slide) { const m = slide.querySelector('video.mp-media'); if (m && m.getAttribute('src')) { try { m.pause(); m.removeAttribute('src'); m.load(); } catch {} } @@ -12444,13 +12604,16 @@ export function messagesFeature(ctx) { // The pager's own glide from slide to slide: the browser's smooth scroll // dawdles (half a second and more on Android). Quick and eased out, faster // still after a fast flick; snapping stays off until it lands. - function pagerGlide(box, axis, to, vel = 0) { + // A longer glide (a flight past several posts) says how long it takes; + // onDone hears the landing, or the interruption (true) that caught it. + function pagerGlide(box, axis, to, vel = 0, { ms: want = 0, onDone = null } = {}) { + pagerHold(box); // a glide under way is caught where it is const y = axis === 'y', from = y ? box.scrollTop : box.scrollLeft, dist = to - from; - cancelAnimationFrame(box._glide); box.style.scrollSnapType = 'none'; - const done = () => { box._glide = 0; box.style.scrollSnapType = ''; }; + box._onDone = onDone; + const done = (cut) => { box._glide = 0; box.style.scrollSnapType = ''; const f = box._onDone; box._onDone = null; if (f) f(!!cut); }; if (Math.abs(dist) < 1) { done(); return; } - const ms = Math.max(60, Math.min(130, Math.abs(dist) / Math.max(5, Math.abs(vel) * 1.5))); + const ms = want || Math.max(60, Math.min(130, Math.abs(dist) / Math.max(5, Math.abs(vel) * 1.5))); // timed from its first frame: a frame's time can be a little before now, // and a glide timed from now began by stepping back let t0 = 0; @@ -12469,17 +12632,160 @@ export function messagesFeature(ctx) { const b = box.getBoundingClientRect(), r = el.getBoundingClientRect(); return y ? box.scrollTop + r.top - b.top : box.scrollLeft + r.left - b.left; }; - const pagerHold = (box) => { if (box && box._glide) { cancelAnimationFrame(box._glide); box._glide = 0; } }; + const pagerHold = (box) => { + if (!box || !box._glide) return; + cancelAnimationFrame(box._glide); box._glide = 0; box.style.scrollSnapType = ''; + const f = box._onDone; box._onDone = null; if (f) f(true); + }; + // the slide on screen — named, not "a screen further", which from a + // scroll still settling landed short + const pagerHere = () => pager && (pagerSlides().find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.left) < r.width / 2 && Math.abs(r.top) < r.height / 2; }) || pager.cur); + // A step: v up or down the posts (-1/1), hz along the post's pictures. + // The keys, the wheel and the desktop arrows all come here. + function pagerStep(v, hz) { + if (!pager) return false; + const here = pagerHere(); + if (v) { + const groups = pagerGroups(); + const from = here && here.closest('.mp-group'); + const index = groups.indexOf(from) + v; + const to = groups[(index + groups.length) % groups.length]; + if (to) { + if (index < 0 || index >= groups.length) pagerWrap(to); + else pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); + } + return !!to; + } + if (hz) { + const to = here && (hz > 0 ? here.nextElementSibling : here.previousElementSibling); + if (to) pagerGlide(here.parentElement, 'x', pagerAt(here.parentElement, to, false)); + return !!to; + } + return false; + } + // A spin of the wheel flies. Each notch adds a post to go — the third and + // later notches of a quick run add two, so a flick of the wheel is a few + // posts and a spin a dozen — and the pager glides through them, the + // pictures passing, to land snapped on the last. The posts passed are not + // arrived at (no clip starts, nothing is marked seen): only the landing + // is (pagerArrive). A notch back against a flight catches it on the post + // it is passing and steps back from there; a touch or a key catches it + // too (pagerHold → onDone). A notch from rest is the single step it was. + let fly = null; // { to: the post flown to, n: notches in this run, at, dir } + function pagerFly(dir, now) { + if (!pager) return; + const stops = pagerGroups(); + if (!stops.length) return; + const live = fly && now - fly.at < 400 && pager.scroller._glide; + let from, n = 1; + if (live && fly.dir === dir) { from = fly.to; n = fly.n + 1; } + else if (live) { // against the flight: the post it is passing + const y = pager.scroller.scrollTop; + from = stops.reduce((b, g) => (Math.abs(g.offsetTop - y) < Math.abs(b.offsetTop - y) ? g : b)); + } else { + const here = pagerHere(); + from = here && here.closest('.mp-group'); + } + const next = stops.indexOf(from) + dir * (n <= 2 ? 1 : 2); + const i = (next % stops.length + stops.length) % stops.length; + if (next < 0 || next >= stops.length) { fly = null; pagerWrap(stops[i]); return; } + const to = stops[i]; + fly = { to, n, at: now, dir }; + if (live) pager.scroller._onDone = null; // the flight goes on: not a landing + pagerFlyTo(to); + } + // The flight itself: a glide to the post, the pictures passing, landing + // snapped on it — only the landing is arrived at. A flick of a finger + // (pace: a little slower per screen than the wheel's) comes here too. + function pagerFlyTo(to, { vel = 0, pace = 110, most = 1200 } = {}) { + const dest = pagerAt(pager.scroller, to, true); + const screens = Math.abs(dest - pager.scroller.scrollTop) / Math.max(1, pager.scroller.clientHeight); + pager.flying = true; + pagerGlide(pager.scroller, 'y', dest, vel, { + ms: screens > 1.05 ? Math.min(most, 100 + pace * screens) : 0, + onDone: () => { if (!pager) return; pager.flying = false; const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); }, + }); + } + const pagerArrive = (slide) => { if (pager && pager.arrive) pager.arrive(slide); }; + // the sideways arrows show only where the post has a picture that way + function pagerArrows(slide) { + if (!pager || !slide) return; + pager.el.classList.toggle('has-left', !!slide.previousElementSibling); + pager.el.classList.toggle('has-right', !!slide.nextElementSibling); + } + + // Crossing the boundary jumps directly to the other end, without flying + // backwards through every loaded post or waiting for a network request. + function pagerWrap(group) { + if (!pager || !group) return; + pager.scroller._onDone = null; + pagerHold(pager.scroller); + pager.scroller.style.scrollSnapType = ''; + pager.flying = false; + const slide = rowSlide(group); + if (slide) { pagerShow(slide); pagerArrive(slide); } + } + function pagerPosition() { + if (!pager) return; + const groups = pagerGroups(); + const slide = pager.cur || pager.anchor; + const index = groups.indexOf(slide && slide.closest('.mp-group')); + pager.el.querySelector('.mp-position').textContent = (index < 0 ? 0 : index + 1) + ' / ' + groups.length; + } function buildPager(want) { const video = want.kind === 'videos'; const scroller = h('div', { class: 'mp-scroll' }); const el = h('div', { class: 'media-pager' + (video ? ' videos' : '') + (pagerLoud ? '' : ' quiet'), role: 'dialog', 'aria-modal': 'true' }, scroller, + h('div', { class: 'mp-position', role: 'status' }, '0 / 0'), h('div', { class: 'mp-top' }, h('button', { class: 'mp-x', type: 'button', 'aria-label': t('close'), onClick: (e) => { e.stopPropagation(); dismissPager(); } }, '×'), video ? h('button', { class: 'mp-sound', type: 'button', onClick: (e) => { e.stopPropagation(); pagerSound(!pagerLoud); } }, - h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null)); + h('span', { class: 'on' }, '\u{1F50A}'), h('span', { class: 'off' }, '\u{1F507} ' + t('videoUnmute'))) : null), + // a mouse's way through (shown where there is a pointer to hover with, + // style.css): up and down the posts at the right edge, left and right + // along a post's pictures when it has more than one (has-left/right) + h('div', { class: 'mp-arrows' }, ...[['mp-up', 'pagerPrevPost', 'M6 15l6-6 6 6', -1, 0], ['mp-down', 'pagerNextPost', 'M6 9l6 6 6-6', 1, 0], + ['mp-ar-left', 'pagerPrevPic', 'M15 6l-6 6 6 6', 0, -1], ['mp-ar-right', 'pagerNextPic', 'M9 6l6 6-6 6', 0, 1]].map(([cls, label, d, v, hz]) => + h('button', { class: 'mp-arrow ' + cls, type: 'button', 'aria-label': t(label), title: t(label), onClick: (e) => { e.stopPropagation(); pagerStep(v, hz); } }, + h('span', { style: 'display:flex', html: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="' + d + '"/></svg>' }))))); + // A mouse wheel pages: a notch down is the next post, a notch up the one + // before, and a spin of notches is a flight past that many (pagerFly), + // landing snapped on a post. A trackpad's run of small deltas is one + // step per run (its first 40px; the rest of the run, until a pause, is + // spent), and a sideways run — or shift+wheel — walks along the post's + // pictures, a breath between steps. Ctrl+wheel (a + // trackpad's pinch comes the same way) zooms the picture and the wheel + // pans it while zoomed: attachZoom takes those first (defaultPrevented). + // Left to the browser, a notch scrolled the snap container a hundred + // pixels and the glide's snap put it straight back: the wheel did nothing. + let wheelStepAt = 0, run = null; + el.addEventListener('wheel', (e) => { + if (!pager || e.defaultPrevented || e.ctrlKey || e.metaKey) return; + if (e.target.closest && e.target.closest('.mp-caption.open, .mp-seek')) return; + e.preventDefault(); + const k = e.deltaMode === 1 ? 40 : 1; + let dx = e.deltaX * k, dy = e.deltaY * k; + if (e.shiftKey && !dx) { dx = dy; dy = 0; } + const hz = Math.abs(dx) > Math.abs(dy), d = hz ? dx : dy; + if (!d) return; + const notch = e.deltaMode === 1 || (hz ? e.wheelDeltaX && e.wheelDeltaX % 120 === 0 : e.wheelDeltaY && e.wheelDeltaY % 120 === 0); + const now = e.timeStamp; + if (notch && !hz) { pagerFly(Math.sign(d), now); return; } // up and down the posts: a notch, or a spin, flies + if (notch) { + if (now - wheelStepAt < 220) return; + } else { + if (!run || now - run.at > 150) run = { at: now, acc: 0, done: false }; + run.at = now; + if (run.done) return; + run.acc += d; + if (Math.abs(run.acc) < 40) return; + run.done = true; + } + wheelStepAt = now; + pagerStep(hz ? 0 : Math.sign(d), hz ? Math.sign(d) : 0); + }, { passive: false }); // A touch is the pager's to judge, not the browser's. Left to native // scrolling, a tap whose finger moved a few pixels was taken for a // scroll (no click: a tap meant to pause a clip did nothing), and a quick @@ -12491,6 +12797,19 @@ export function messagesFeature(ctx) { // the browser sent no click): a tap meant to pause a clip drifts. const FOLLOW = 10, TAP_SLOP = 24, TAP_MS = 350; // px; px; ms const SWIPE_X = 0.1, SWIPE_Y = 0.12, FLICK = 0.2, FLICK_MIN = 24; // of the screen; px/ms; px + // A fast flick up or down flies past several posts, the way a spin of + // the wheel does, and lands snapped on one (Adam, 2026-10-16: "make + // images coast on the phone too when I swipe"): as many posts as the + // fling would have coasted — the speed times FLY_TAU, in screens — from + // FLY_V up; a slower swipe is the one post it was. The ends are not + // flown past: a fling that would, lands on the last (or first) post. + // (3 px/ms and a 500 ms coast took a hard flick to fly at all — Adam, + // 2026-10-16: "too hard to get it to fly"; then 800 ms and six posts: + // "make it fly longer before it settles down… I can slow it down by + // touching again when I see something I like".) So a long coast, taken + // slowly enough to watch and to catch: a finger down holds it. + const FLY_V = 1.6, FLY_TAU = 1600, FLY_MAX = 14; // px/ms; ms; posts + const TRACK_MS = 80; // the speed is the finger's over its last moment, not its last few samples let touch = null, pendingTap = 0, tapDoneAt = 0; el.addEventListener('touchstart', (e) => { const t = e.touches[0]; @@ -12518,16 +12837,24 @@ export function messagesFeature(ctx) { d.far = Math.max(d.far || 0, Math.hypot(dx, dy)); if (d.axis === 'y') d.box.scrollTop = d.start - delta; else d.box.scrollLeft = d.start - delta; d.track.push({ t: performance.now(), v: delta }); // (when handled: synthetic moves can share a timeStamp) - if (d.track.length > 5) d.track.shift(); + while (d.track.length > 2 && d.track[d.track.length - 1].t - d.track[1].t >= TRACK_MS) d.track.shift(); }, { passive: false }); // let go of a drag: on to the next (or the one before), or back function settle(d, step, vel = 0) { const box = d.box, y = d.axis === 'y'; - const stops = [...box.children].filter((c) => c.matches(y ? '.mp-group, .mp-tail' : '.mp-slide')).map((c) => pagerAt(box, c, y)); + const stops = [...box.children].filter((c) => c.matches(y ? '.mp-group' : '.mp-slide')).map((c) => pagerAt(box, c, y)); if (!stops.length) return; - // the stop it set off from, then one along (or back) + // the stop it set off from, then one along (or back) — or, flung, several const from = stops.reduce((bi, p, i) => (Math.abs(p - d.start) < Math.abs(stops[bi] - d.start) ? i : bi), 0); - pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, from + step))], vel); + const posts = y && step && Math.abs(vel) >= FLY_V ? Math.min(FLY_MAX, Math.max(1, Math.round(Math.abs(vel) * FLY_TAU / Math.max(1, box.clientHeight)))) : 1; + const next = from + step * posts; + if (y && posts > 1) { + const i = Math.min(stops.length - 1, Math.max(0, next)); + if (i !== from) { pagerFlyTo(pagerGroups()[i], { vel, pace: 260, most: 4000 }); return; } + } + // past either end: round to the other (a fling at the end too — one post of wrap, not its whole count) + if (y && (next < 0 || next >= stops.length)) { pagerWrap(pagerGroups()[(from + step + stops.length) % stops.length]); return; } + pagerGlide(box, d.axis, stops[Math.min(stops.length - 1, Math.max(0, next))], vel); } el.addEventListener('touchend', (e) => { const d = touch; @@ -12548,20 +12875,26 @@ export function messagesFeature(ctx) { clearTimeout(pendingTap); pendingTap = setTimeout(() => { pendingTap = 0; - tapDoneAt = Date.now(); const btn = d.target.closest && d.target.closest('button, a'); - if (btn) btn.click(); else pagerTap(d.target); + if (btn) btn.click(); else pagerTap(d.target); // (the click here is before tapDoneAt: not swallowed) + tapDoneAt = Date.now(); }, 120); }, { passive: true }); el.addEventListener('touchcancel', () => { if (touch && touch.box) settle(touch, 0); touch = null; }, { passive: true }); + // The browser's click for a touch: it IS the tap, so the pending one + // stands down — judged on the way down (capture), before a button's own + // handler stops the bubble: the sound button's click never reached this + // listener, the pending tap clicked it again 120 ms later, and a clip + // could not be muted (Adam, 2026-10-16). A click after the pending tap + // has already been done is swallowed before it reaches anything. el.addEventListener('click', (e) => { - if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; } // the browser's click came: it's the tap - else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); return; } // done already - pagerTap(e.target); - }); + if (pendingTap) { clearTimeout(pendingTap); pendingTap = 0; } + else if (Date.now() - tapDoneAt < 700) { e.stopPropagation(); e.preventDefault(); } + }, true); + el.addEventListener('click', (e) => pagerTap(e.target)); // a tap shows (or hides) who posted it and what they said function pagerTap(target) { - if (target.closest('button, a, .mp-caption, .mp-seek, .mp-tail')) return; + if (target.closest('button, a, .mp-caption, .mp-seek')) return; if (Date.now() - videoTapAt < 800) return; // the click of the touch that opened it if (pager && video) { // A clip (from the feed or the media grid): a tap anywhere but the @@ -12574,12 +12907,13 @@ export function messagesFeature(ctx) { else if (pager.cur) playSlide(pager.cur); return; } - if (pager && pager.fromFeed) { - // Opened from a post in the feed: a tap on the picture that opened - // it closes it, as a full-screen picture always has. Once the reader - // has moved on, the first tap shows the post's details, and the next - // closes the pager onto that post in the feed. - if (pagerOnStart()) { closeMediaPager(); return; } + if (pager && pagerSource()) { + // Opened from a post on a page (the feed, a profile, a thread): the + // first tap shows the post's details — its caption and controls — + // and the next closes the pager onto that post. (A tap on the + // picture that opened it used to close it outright, as a full-screen + // picture had; Adam, 2026-10-16: a tap should bring the details up + // wherever the picture was opened from.) if (!el.classList.contains('show')) { el.classList.add('show'); pagerCounts(pager.cur); return; } dismissPager(); return; @@ -12587,33 +12921,7 @@ export function messagesFeature(ctx) { el.classList.toggle('show'); if (el.classList.contains('show')) pagerCounts(pager && pager.cur); } - pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, start: want.start, at: want.at || 0 }; - // The end of the pager: swiping past the last slide lands here, on - // something saying more is coming (not a nudge that goes nowhere) — and - // asks for it at once. Slides that arrive go in above it; one arriving - // while the reader waits here is moved onto, and plays. With nothing - // more to be had, it says so. - const tail = h('div', { class: 'mp-tail', 'aria-live': 'polite' }, - h('div', { class: 'mp-loader', 'aria-hidden': 'true', html: video - ? '<span class="mp-orbit"><i></i><i></i><i></i></span><span class="mp-core"><svg viewBox="0 0 24 24" width="30" height="30"><path d="M8 5.5v13l10.5-6.5z" fill="currentColor"/></svg></span>' - : '<span class="mp-orbit"><i></i><i></i><i></i></span><span class="mp-core"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><rect x="3.5" y="4.5" width="17" height="15" rx="2.5"/><path d="M3.5 16l5-5 4 4 2.5-2.5 5 5"/><circle cx="15.5" cy="9" r="1.6" fill="currentColor" stroke="none"/></svg></span>' }), - h('div', { class: 'mp-tail-text' }, t(video ? 'pagerFetchingVideo' : 'pagerFetchingPictures'))); - tail._tail = true; - scroller.append(tail); - pager.tail = tail; - pager.tailIo = new IntersectionObserver((entries) => { - for (const e of entries) { - if (!pager) return; - pager.onTail = e.isIntersecting && e.intersectionRatio >= 0.6; - if (!pager.onTail) continue; - const v = pager.cur && pager.cur.querySelector('video.mp-media'); - if (v) v.pause(); - el.classList.remove('show'); - if (pager.fromFeed) pagerFeedAhead(); else crawlMedia().catch(() => {}); - pagerTailState(); - } - }, { root: scroller, threshold: [0, 0.6] }); - pager.tailIo.observe(tail); + pager = { el, scroller, kind: want.kind, keys: new Set(), cur: null, feed: want.feed, fromFeed: !!want.fromFeed, profile: want.profile || null, thread: want.thread || null, start: want.start, at: want.at || 0 }; pager.near = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting) continue; @@ -12624,23 +12932,36 @@ export function messagesFeature(ctx) { if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) pagerAttach(x); } }, { root: scroller, rootMargin: '150% 0px' }); + // a slide has come to rest on screen (or a flight has landed on it) + pager.arrive = (slide) => { + if (!pager) return; + if (pager.cur && pager.cur !== slide) { + const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause(); + // how long a clip held the reader (the head start's measure of them) + if (v && pager.curAt) { const secs = (Date.now() - pager.curAt) / 1000; dwell = dwell ? dwell * 0.7 + secs * 0.3 : secs; } + if (pagerSource() && video) el.classList.remove('show'); + } + if (pager.cur) pager.cur.classList.remove('mp-cur'); + slide.classList.add('mp-cur'); // (the one with the layer for a pinch: style.css) + pager.cur = slide; + pager.anchor = slide; + pager.curAt = Date.now(); + pagerSharpen(slide); + pagerArrows(slide); + pagerPosition(); + if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === slide._rowId); markSeen(row || slide._ev); } + else if (pager.profile || pager.thread) markSeen(slide._ev); + playSlide(slide); + pagerCounts(slide); + pagerLoadAhead(); + }; pager.io = new IntersectionObserver((entries) => { for (const e of entries) { if (!e.isIntersecting || e.intersectionRatio < 0.6 || !pager) continue; - if (pager.cur && pager.cur !== e.target) { - const v = pager.cur.querySelector('video.mp-media'); if (v) v.pause(); - // how long a clip held the reader (the head start's measure of them) - if (v && pager.curAt) { const secs = (Date.now() - pager.curAt) / 1000; dwell = dwell ? dwell * 0.7 + secs * 0.3 : secs; } - if (pager.fromFeed && video) el.classList.remove('show'); - } - pager.cur = e.target; - pager.curAt = Date.now(); - if (pager.fromFeed) { const row = feedNow().notes.find((x) => x.id === e.target._rowId); markSeen(row || e.target._ev); } - playSlide(e.target); - pagerCounts(e.target); - // near the end: ask the feed for more, the pager picks them up - if (pager.fromFeed) pagerFeedAhead(); - else { const gs = pagerGroups(); if (gs.indexOf(e.target.closest('.mp-group')) >= gs.length - 3) crawlMedia().catch(() => {}); } + if (pager.flying) continue; // passing, not arriving: the landing is pagerFly's + // a slide the screen's new size put there, not the reader: the anchor goes back + if (pager.screenH && scroller.clientHeight !== pager.screenH) { pager.onResize(); continue; } + pager.arrive(e.target); } }, { root: scroller, threshold: [0.6] }); growPager(); @@ -12648,6 +12969,9 @@ export function messagesFeature(ctx) { document.documentElement.classList.add('no-scroll'); const first = pagerSlides().find((s) => s.getAttribute('data-k') === want.start) || pagerSlides()[0]; if (first) pagerShow(first); + pager.anchor = first; // the slide meant to be on screen, from the first moment + pager.screenH = scroller.clientHeight; + pagerLoadAhead(); // the clip on screen's seek bar, every frame (timeupdate comes a few times a second) if (video) { const p = pager; @@ -12655,35 +12979,70 @@ export function messagesFeature(ctx) { p.raf = requestAnimationFrame(frame); } // turned on its side (or back), the slides change height: stay on this one - pager.onResize = () => { if (pager && pager.cur) pagerShow(pager.cur); }; + // The screen changed size — a turn of the phone, or the page going full + // screen for the pager (the address bar and tabs gone: a taller window). + // The posts are a screen tall each, so a scroll position in pixels + // deep in the pager now sits in a different post: put the one meant + // back on screen. The slide meant is the anchor, set the moment the + // pager opens (cur is set by the observer a frame later, and the full + // screen's resize beat it: Adam's full-screen button on a clip 23 posts + // down landed him on another post's picture) and on each arrival. + pager.onResize = () => { + if (!pager) return; + pager.screenH = pager.scroller.clientHeight; + if (pager.anchor) pagerShow(pager.anchor); + }; window.addEventListener('resize', pager.onResize); // new posts land in the feed while the pager is open; counts move - pager.tick = setInterval(() => { if (pager) { growPager(); pagerFeedAhead(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500); + pager.tick = setInterval(() => { if (pager) { growPager(); pagerLoadAhead(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500); } - // A pager opened from the feed walks the feed's own posts, which page in - // ten at a time as the feed is scrolled. Swiping is the scrolling here: - // with only a few posts left below the one on screen (PAGER_AHEAD), the feed pages on - // (the posts it holds, then older ones from the relays) until there are - // more — several pages if those posts carry none. A page that brings - // nothing (the feed busy, or at its end) stops it; the tick tries again. - // how many posts ahead to keep: clips are fewer and further between - // (more pages to find the next) and each is watched a while, so further - const PAGER_AHEAD = { images: 4, videos: 6 }; - let pagerFeeding = false; - async function pagerFeedAhead() { - if (pagerFeeding || !pager || !pager.fromFeed) return; - pagerFeeding = true; + // All media pagers build the same buffer, retaining the source's ordering + // and filters. The furthest post reached never moves back when we wrap, + // so pending refills continue while the reader circles through older items. + const PAGER_INITIAL = 30, PAGER_AHEAD = 20; + function pagerLoadSource(p) { + const src = pagerSource(p); + if (src) return { + more: src.more, done: src.done, + progress: () => src.c.shown + ':' + src.c.notes.length, + }; + // A profile cache can be replaced while refreshing; never fall back + // to another source in that gap. The next timer tick will try again. + if (p.profile || p.fromFeed) return { more: async () => {}, done: () => true, progress: () => '' }; + const c = feedNow(), r = mediaIndex(c.id); + const curated = feedDef(c.id)?.curated; + return { + more: () => crawlMedia(c, p.kind), + done: () => c.stopped || (curated ? c.end && c.shown >= c.notes.length : !!r.end), + progress: () => [r.until, r[p.kind].size, c.shown, c.notes.length].join(':'), + }; + } + async function pagerLoadAhead() { + const p = pager; + if (!p) return; + const groups = pagerGroups(); + const slide = p.cur || p.anchor; + p.furthest = Math.max(p.furthest ?? -1, groups.indexOf(slide && slide.closest('.mp-group'))); + if (p.loadingAhead || Date.now() < (p.retryAheadAt || 0)) return; + p.loadingAhead = true; + let stalled = false; try { - const c = feedNow(); - for (let pages = 0; pages < 8 && pager && pager.fromFeed; pages++) { - const gs = pagerGroups(); - if (gs.length - 1 - gs.indexOf(pager.cur && pager.cur.closest('.mp-group')) > (PAGER_AHEAD[pager.kind] || 4)) break; - const shown = c.shown, held = c.notes.length; - await loadOlderFeed(); + // Bound each run; the timer resumes if sparse media needs more pages. + for (let pages = 0; pages < 8 && pager === p; pages++) { + if (pagerGroups().length >= Math.max(PAGER_INITIAL, p.furthest + 1 + PAGER_AHEAD)) break; + const src = pagerLoadSource(p); + if (src.done()) break; + const before = src.progress(); + await src.more(); + if (pager !== p) return; growPager(); - if (c.shown === shown && c.notes.length === held) break; + if (src.progress() === before) { stalled = true; break; } } - } catch {} finally { pagerFeeding = false; } + } catch { stalled = true; } finally { + p.loadingAhead = false; + // An empty or busy source gets a breather, never concurrent requests. + p.retryAheadAt = Date.now() + (stalled ? 10000 : 1500); + } } // Slides for feed items the pager doesn't have yet: into their post's row // (a quote fetched late joins its post), a new post appended below (never @@ -12691,10 +13050,9 @@ export function messagesFeature(ctx) { function growPager() { if (!pager) return; const c = feedNow(); - const waiting = pager.onTail; - let first = null; pager.groups ||= new Map(); - for (const it of (pager.fromFeed ? feedMediaItems(c, pager.kind) : mediaItems(c, pager.kind))) { + const src = pagerSource(); + for (const it of (src ? src.items(pager.kind) : mediaItems(c, pager.kind))) { const k = mediaKey(it); if (pager.keys.has(k)) continue; pager.keys.add(k); @@ -12704,17 +13062,14 @@ export function messagesFeature(ctx) { if (!g) { g = pagerGroup(); pager.groups.set(gk, g); - pager.scroller.insertBefore(g, pager.tail || null); - first ||= s; + pager.scroller.append(g); } g._row.append(s); pagerDots(g); pager.near.observe(s); pager.io.observe(s); } - // the reader was waiting at the end: onto the first that came - if (waiting && first) { pager.onTail = false; pagerShow(first); } - pagerTailState(); + pagerPosition(); } // A post in the pager: its row of slides, side by side, and — with more // than one — dots saying how many and which. @@ -12732,39 +13087,15 @@ export function messagesFeature(ctx) { const at = rowSlide(g); [...g._dots.children].forEach((d, i) => d.classList.toggle('on', g._row.children[i] === at)); } - // the end says "more coming", or — the feed (or the media index) has no - // more to give — that this is everything - function pagerTailState() { - if (!pager || !pager.tail) return; - const c = feedNow(); - const done = pager.fromFeed ? !!c.end && c.shown >= c.notes.length && !c.loadingMore : !!mediaIndex(c.id).end; - if (pager.tail.classList.contains('done') === done) return; - pager.tail.classList.toggle('done', done); - pager.tail.querySelector('.mp-tail-text').textContent = done ? t('pagerCaughtUp') : t(pager.kind === 'videos' ? 'pagerFetchingVideo' : 'pagerFetchingPictures'); - } if (typeof window !== 'undefined') { window.addEventListener('keydown', (e) => { if (!pager || e.defaultPrevented) return; + if (ui.reactPick) { if (e.key === 'Escape') { e.preventDefault(); ui.reactPick = null; ui.emojiPick = null; render(); } return; } // the emoji sheet's if (e.key === 'Escape') { e.preventDefault(); dismissPager(); return; } // up and down: the next post, or the one before; left and right: along this post's row const v = ['ArrowDown', 'j', 'PageDown'].includes(e.key) ? 1 : ['ArrowUp', 'k', 'PageUp'].includes(e.key) ? -1 : 0; const hz = ['ArrowRight', 'l'].includes(e.key) ? 1 : ['ArrowLeft', 'h'].includes(e.key) ? -1 : 0; - // to the slide (or post) next to the one on screen — named, not "a - // screen further", which from a scroll still settling landed short - const here = pagerSlides().find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.left) < r.width / 2 && Math.abs(r.top) < r.height / 2; }) || pager.cur; - if (v) { - e.preventDefault(); - const from = pager.onTail || !here ? pager.tail : here.closest('.mp-group'); - const to = from && (v > 0 ? from.nextElementSibling : from.previousElementSibling); - if (to) pagerGlide(pager.scroller, 'y', pagerAt(pager.scroller, to, true)); - return; - } - if (hz) { - e.preventDefault(); - const to = !pager.onTail && here && (hz > 0 ? here.nextElementSibling : here.previousElementSibling); - if (to) pagerGlide(here.parentElement, 'x', pagerAt(here.parentElement, to, false)); - return; - } + if (v || hz) { e.preventDefault(); pagerStep(v, hz); return; } if (e.key === ' ' && pager.cur) { const v = pager.cur.querySelector('video.mp-media'); if (v) { e.preventDefault(); if (v.paused) v.play().catch(() => {}); else v.pause(); } @@ -14927,7 +15258,7 @@ export function messagesFeature(ctx) { // group-chat state is device state in IndexedDB: it goes with the account wipeCache(bases) { import('../marmot-store.js').then((m) => { for (const b of bases) m.wipeMarmot(b); }).catch(() => {}); }, forgetAll() { wipeMediaIndex(); mediaIdx.clear(); try { caches.delete(VIDEO_THUMB_CACHE); } catch {} }, - nostrSettingsCards() { return [relaysCard(), followHistoryCard(), moderationCard(), translateCard(), videosCard()]; }, + nostrSettingsCards() { return [relaysCard(), followHistoryCard(), groupDevicesCard(), moderationCard(), translateCard(), videosCard()]; }, // The app came back after being backgrounded. A phone freezes a hidden // tab: the relay sockets are cut and every post made in the meantime is // simply missing, which is why the feed used to sit there looking stale diff --git a/src/features/zaps.js b/src/features/zaps.js index d327f9d..d364738 100644 --- a/src/features/zaps.js +++ b/src/features/zaps.js @@ -13,7 +13,9 @@ import { t } from '../i18n.js'; import { npubOf } from '../nostr.js'; -import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice } from '../lnurl.js'; +import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin, submitWithdraw } from '../lnurl.js'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; import { decodeNoffer } from '../noffer.js'; import { requestOfferInvoice } from '../clink.js'; import { decodeOffer } from '../bolt12.js'; @@ -134,7 +136,13 @@ export function zapsFeature(ctx) { z.address = lnTarget.address || z.address; } - const params = await fetchPayParams(target.url); + let params; + try { params = await fetchPayParams(target.url); } + catch (e) { + // not a payment after all: the site wants to pay US (LUD-03) + if (e && e.withdraw) { if (ui.zap === z) beginWithdraw(e.withdraw, z); return; } + throw e; + } if (ui.zap !== z) return; // user navigated away // Name the payee like a human: LNURL metadata usually carries the // lightning address (text/identifier) — show that instead of a bech32 @@ -290,6 +298,125 @@ export function zapsFeature(ctx) { } } + // ---- Lightning login (LNURL-auth) ---- + // A site's login lnurl pasted (or tapped) into Send: not a payment but a + // challenge to sign. Ask first — it is an identity act, naming the site — + // then sign with the key this wallet keeps for that site alone (LUD-05) + // and tell the site. (Adam, 2026-10-16: a Stacker News login lnurl in Send + // got "no sig query variable provided" — it was being paid.) + function beginLogin(auth) { + ui.zap = null; ui.sendError = ''; + ui.lnAuth = { auth, status: 'ask' }; + render(); + } + const masterKey = () => { + if (wallet.mnemonic) return HDKey.fromMasterSeed(mnemonicToSeedSync(wallet.mnemonic, wallet.passphrase || '')); + if (wallet.xprv) return HDKey.fromExtendedKey(wallet.xprv); + return null; + }; + async function doLogin(a) { + a.status = 'busy'; render(); + try { + const master = masterKey(); + if (!master) throw new Error(t('lnAuthNoKey')); + await lnurlAuthLogin(a.auth, lnurlAuthKey(master, a.auth.domain)); + if (ui.lnAuth !== a) return; + a.status = 'done'; + } catch (e) { + if (ui.lnAuth !== a) return; + a.status = 'error'; a.error = e.message || String(e); + } + render(); + } + function lnAuthView() { + const a = ui.lnAuth; + if (!a) return null; + const { domain, action } = a.auth; + const back = () => { ui.lnAuth = null; ui.sendError = ''; ui.send = blankSend(); render(); }; + const heading = '🔑 ' + t('lnAuthTitle'); + if (a.status === 'busy') { + return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' }, + h('span', { class: 'spinner' }), + h('p', { class: 'muted', style: 'margin:0' }, t('lnAuthSigning', { domain }))); + } + if (a.status === 'done') { + return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' }, + h('div', { class: 'check-badge' }, '✓'), + h('h3', { style: 'margin:0' }, t('lnAuthDoneTitle', { domain })), + h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthDoneHint')), + h('button', { class: 'btn-primary btn-block', onClick: back }, t('done'))); + } + const ask = t(action === 'register' ? 'lnAuthAskRegister' : action === 'link' ? 'lnAuthAskLink' : action === 'auth' ? 'lnAuthAskAuth' : 'lnAuthAskLogin', { domain }); + return h('div', { class: 'card col', style: 'gap:12px' }, + h('h3', {}, heading), + h('div', { class: 'small', style: 'font-size:18px;font-weight:600;word-break:break-all' }, domain), + h('p', { class: 'small muted', style: 'margin:0' }, ask), + h('p', { class: 'small muted', style: 'margin:0' }, t('lnAuthHow')), + a.status === 'error' ? h('div', { class: 'notice err' }, a.error || t('lnAuthFailed')) : null, + h('button', { class: 'btn-primary btn-block', onClick: () => doLogin(a) }, a.status === 'error' ? t('lnAuthRetry') : t(action === 'register' ? 'lnAuthGoRegister' : 'lnAuthGo')), + h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); + } + // ---- Lightning withdraw (LNURL-withdraw) ---- + // A site's withdraw lnurl: they will pay an invoice of ours, within their + // range. Ask the amount (their max to start; fixed when min = max), make + // the invoice over Ark, hand it over, and watch for the money. + function beginWithdraw(w, z) { + const maxSat = Math.floor(w.maxMsat / 1000), minSat = Math.ceil(w.minMsat / 1000); + ui.zap = null; ui.sendError = ''; + ui.lnWithdraw = { w, minSat, maxSat, fixed: minSat >= maxSat, amount: ctx.amountField(maxSat), status: 'ask', address: z && z.address }; + render(); + } + async function doWithdraw(d) { + const sats = d.fixed ? d.maxSat : (parseAmount(d.amount, getUnit()) || 0); + if (!(sats >= Math.max(1, d.minSat) && sats <= d.maxSat)) { d.error = t('lnWithdrawRange', { min: fmtAmount(d.minSat), max: fmtAmount(d.maxSat), unit: unitLabel() }); render(); return; } + if (wallet.watchOnly) { d.error = t('lnWithdrawWatchOnly'); render(); return; } + d.sats = sats; d.status = 'busy'; d.error = ''; render(); + try { + const inv = await hook('arkLnInvoice', sats, (d.w.description || t('lnWithdrawMemo', { domain: d.w.domain })).slice(0, 120)); + if (!inv || !inv.invoice) throw new Error(t('lnWithdrawNoInvoice')); + if (ui.lnWithdraw !== d) { hook('arkLnCancel', inv.id); return; } + await submitWithdraw(d.w, inv.invoice); + if (ui.lnWithdraw !== d) return; + d.status = 'sent'; d.lnId = inv.id; + hook('arkLnWatch', inv.id, (a) => { if (ui.lnWithdraw !== d) return; d.status = a && a.step === 'done' ? 'paid' : 'error'; if (d.status === 'error') d.error = t('lnWithdrawNotPaid'); render(); }); + } catch (e) { + if (ui.lnWithdraw !== d) return; + d.status = 'error'; d.error = e.message || String(e); + } + render(); + } + function lnWithdrawView() { + const d = ui.lnWithdraw; + if (!d) return null; + const domain = d.w.domain; + const back = () => { if (d.lnId && d.status !== 'paid') hook('arkLnUnwatch', d.lnId); ui.lnWithdraw = null; ui.sendError = ''; ui.send = blankSend(); render(); }; + const heading = '⚡ ' + t('lnWithdrawTitle'); + if (d.status === 'busy') { + return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' }, + h('span', { class: 'spinner' }), h('p', { class: 'muted', style: 'margin:0' }, t('lnWithdrawAsking', { domain }))); + } + if (d.status === 'sent' || d.status === 'paid') { + const paid = d.status === 'paid'; + return h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:14px;padding:32px 14px' }, + paid ? h('div', { class: 'check-badge' }, '✓') : h('span', { class: 'spinner' }), + h('h3', { style: 'margin:0' }, paid ? t('lnWithdrawPaid', { amount: fmtAmount(d.sats) + ' ' + unitLabel() }) : t('lnWithdrawSent', { amount: fmtAmount(d.sats) + ' ' + unitLabel(), domain })), + paid ? null : h('p', { class: 'small muted', style: 'margin:0' }, t('lnWithdrawSentHint')), + h('button', { class: paid ? 'btn-primary btn-block' : 'btn-ghost btn-block', onClick: back }, paid ? t('done') : t('back'))); + } + return h('div', { class: 'card col', style: 'gap:12px' }, + h('h3', {}, heading), + h('div', { class: 'small', style: 'font-size:18px;font-weight:600;word-break:break-all' }, domain), + d.w.description ? h('p', { class: 'small muted', style: 'margin:0' }, d.w.description) : null, + h('p', { class: 'small muted', style: 'margin:0' }, d.fixed ? t('lnWithdrawFixed', { amount: fmtAmount(d.maxSat) + ' ' + unitLabel(), domain }) + : t('lnWithdrawOffer', { min: fmtAmount(d.minSat), max: fmtAmount(d.maxSat), unit: unitLabel(), domain })), + d.fixed ? null : h('div', { class: 'row gap6', style: 'align-items:center' }, + h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: d.amount, onInput: (e) => { d.amount = e.target.value; d.error = ''; render(); } }), + unitTag(), + h('button', { type: 'button', onClick: () => { d.amount = ctx.amountField(d.maxSat); d.error = ''; render(); } }, t('max'))), + d.error ? h('div', { class: 'notice err' }, d.error) : null, + h('button', { class: 'btn-primary btn-block', onClick: () => doWithdraw(d) }, d.status === 'error' ? t('lnAuthRetry') : t('lnWithdrawGo')), + h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); + } function zapView() { const z = ui.zap; if (!z) return null; @@ -391,6 +518,9 @@ export function zapsFeature(ctx) { id: 'zaps', // A lightning address / lnurl (always), or an npub that Ark didn't claim. matchSendText(text, typed) { + // 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; const tap = !typed; // scanned/pasted (a tap), not a keystroke const nof = maybeNoffer(text); @@ -472,8 +602,8 @@ export function zapsFeature(ctx) { begin({ kind: 'npub', pk, eventId: eventId || null }, shortNpub(npub || npubOf(pk))); return true; }, - resetSend() { ui.zap = null; }, - sendView() { return zapView(); }, + resetSend() { ui.zap = null; ui.lnAuth = null; ui.lnWithdraw = null; }, + sendView() { return lnAuthView() || lnWithdrawView() || zapView(); }, // Settings → Nostr: the one-tap zap amount. nostrSettingsCards() { const cur = ctx.zapDefaultSat ? ctx.zapDefaultSat() : 0; diff --git a/src/i18n.js b/src/i18n.js index 82004a2..2b33a47 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -274,8 +274,23 @@ const STR = { pagerFetchingVideo: "Fetching the next video…", pagerFetchingPictures: "Fetching more pictures…", pagerCaughtUp: "That’s everything for now", + pagerPrevPost: 'Previous post', + pagerNextPost: 'Next post', + pagerPrevPic: 'Previous picture', + pagerNextPic: 'Next picture', videoNoPicture: "This browser can’t show this video’s picture", videoOpenFile: "Open the file", + wnDevicesTitle: 'Group invitations', + wnDevicesHelp: 'White Noise and other Marmot apps invite one of your devices, and only that device gets the group. Keep the invitations coming to the device you actually use.', + wnDeviceHere: 'This device can be invited.', + wnDeviceRetired: 'This device no longer takes invitations; another one of yours does.', + wnDeviceNone: 'This device has no invitation key published yet.', + wnDevicesOthers: '{n} other invitation key(s) of yours are published:', + wnDevicesNoOthers: 'No other device of yours is published.', + wnDeviceUnknown: 'another app', + wnOnlyHere: 'Invite this device only', + wnOnlyHereDone: 'Done. {n} other invitation key(s) retired.', + wnOnlyHereFail: 'Couldn’t retire the other keys. Try again.', videoLargeTitle: "Videos", videoLargeToggle: "Autoplay large videos", videoLargeHelp: "Videos over {mb} MB or above 1080p wait for a tap, so the feed doesn’t eat your data. The limit is lower on a slow or data-saving connection.", @@ -516,6 +531,7 @@ const STR = { postBlocked: 'Blocked', zapSending: 'Sending your zap…', msgReply: 'Reply', + mediaPicGone: 'This picture couldn’t be loaded', mediaOpenPost: 'Open the post', profNewPost: 'New post', profYourProfile: 'Your profile', @@ -1462,6 +1478,33 @@ const STR = { destPlaceholder: 'Address or Lightning invoice', lnQuoting: 'Getting a quote…', lnPayTitle: 'Pay over Lightning', + lnAuthTitle: 'Log in with Lightning', + lnAuthAskLogin: '{domain} is asking you to log in with your wallet.', + lnAuthAskRegister: '{domain} is asking you to create an account with your wallet.', + lnAuthAskLink: '{domain} is asking to link your wallet to your account there.', + lnAuthAskAuth: '{domain} is asking you to confirm it’s you, with your wallet.', + lnAuthHow: 'Your wallet signs their challenge with a key it keeps for this site alone. No payment is made, and nothing else about you is shared.', + lnAuthGo: 'Log in', + lnAuthGoRegister: 'Create account', + lnAuthRetry: 'Try again', + lnAuthSigning: 'Signing in to {domain}…', + lnAuthDoneTitle: 'Logged in to {domain}', + lnAuthDoneHint: 'Go back to their page — it should have let you in.', + lnAuthFailed: 'They didn’t accept the login.', + lnAuthNoKey: 'This account has no wallet key to log in with.', + lnWithdrawTitle: 'Withdraw over Lightning', + lnWithdrawOffer: '{domain} will pay you between {min} and {max} {unit}. How much?', + lnWithdrawFixed: '{domain} will pay you {amount}.', + lnWithdrawRange: 'Between {min} and {max} {unit}.', + lnWithdrawGo: 'Withdraw', + lnWithdrawAsking: 'Asking {domain} to pay…', + lnWithdrawSent: '{domain} has your invoice for {amount}', + lnWithdrawSentHint: 'It should arrive in a moment. You can leave this page; the payment will show in your history.', + lnWithdrawPaid: 'Received {amount}', + lnWithdrawNotPaid: 'They took the invoice but the payment didn’t arrive. Check with them before trying again.', + lnWithdrawNoInvoice: 'Couldn’t make an invoice to be paid to.', + lnWithdrawWatchOnly: 'This device can’t receive: the wallet is watch-only here.', + lnWithdrawMemo: 'Withdrawal from {domain}', lnPayAmount: 'Amount', lnPayBoltzFee: 'Swap fee', lnPayNetworkFee: 'Network fee', diff --git a/src/lnurl.js b/src/lnurl.js index 3f1b39b..bfebf0d 100644 --- a/src/lnurl.js +++ b/src/lnurl.js @@ -11,6 +11,10 @@ // that broadcasts the receipt; we only sign the request. import { bech32 } from '@scure/base'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { hmac } from '@noble/hashes/hmac'; +import { sha256 } from '@noble/hashes/sha256'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; import { parseNostrPubkey } from './nostr.js'; const enc = new TextEncoder(); @@ -43,6 +47,9 @@ export function lnAddressToUrl(addr) { export function parseZapTarget(input, { pk = null } = {}) { const s = (input || '').trim().replace(/^lightning:/i, ''); if (!s) return null; + // LUD-17: lnurlp:// (pay) and lnurlw:// (withdraw) are the URL itself over https (http on an onion) + const scheme = /^(lnurlp|lnurlw):\/\/(.+)$/i.exec(s); + if (scheme) { const url = (/^[^/]+\.onion(\/|$)/i.test(scheme[2]) ? 'http://' : 'https://') + scheme[2]; return { kind: 'lnurl', url, lnurlBech32: safeEncode(url), pk }; } // An npub/hex pubkey — the caller resolves lud16/lud06 from the profile and // calls fromProfile() below; here we just report the shape. const npk = parseNostrPubkey(s); @@ -92,10 +99,14 @@ async function fetchJson(url, ms = 8000) { } // GET the LNURL-pay params. Normalizes the bits we use; keeps `callback`. +// An lnurl that turns out to be a withdraw (LUD-03) is not an error here: +// the thrown error carries its terms (`withdraw`) for the caller to take up. export async function fetchPayParams(url) { const d = await fetchJson(url); if (!d || d.tag !== 'payRequest' || !d.callback) { if (d && d.status === 'ERROR') throw new Error(d.reason || 'LNURL error'); + const w = withdrawParamsOf(d); + if (w) { const e = new Error('a Lightning withdraw, not a payment'); e.withdraw = w; throw e; } throw new Error('not a Lightning pay endpoint'); } return { @@ -134,3 +145,71 @@ export async function requestInvoice(params, { amountMsat, zapRequest = null, ln if (!d.pr || typeof d.pr !== 'string') throw new Error('no invoice returned'); return d.pr; } + +// ---- LNURL-auth (LUD-04): a login challenge, not a payment ---- +// An lnurl whose URL carries tag=login and a 32-byte k1. Pasted into Send +// and taken for a payment, their server answered "no sig query variable +// provided" (Stacker News, 2026-10-16). action: login | register | link | auth. +export function parseLnurlAuth(input) { + const s = (input || '').trim().replace(/^lightning:/i, ''); + if (!/^lnurl1[ac-hj-np-z02-9]+$/i.test(s)) return null; + let url; + try { url = new URL(decodeLnurl(s)); } catch { return null; } + if (url.searchParams.get('tag') !== 'login') return null; + const k1 = (url.searchParams.get('k1') || '').toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(k1)) return null; + const action = /^(register|login|link|auth)$/.test(url.searchParams.get('action') || '') ? url.searchParams.get('action') : 'login'; + return { url: url.href, domain: url.hostname, k1, action }; +} +// The linking key for a domain (LUD-05): from the wallet's master key, +// m/138'/0 is the hashing key; its HMAC-SHA256 of the domain, read as four +// 32-bit numbers, names the path m/138'/<n1>/<n2>/<n3>/<n4>. The same +// wallet is the same user on a site, every time, and a different one on +// every other site. +export function lnurlAuthKey(master, domain) { + const hashing = master.derive("m/138'/0").privateKey; + const mac = hmac(sha256, hashing, new TextEncoder().encode(domain)); + const dv = new DataView(mac.buffer, mac.byteOffset, 16); + let key = master.derive("m/138'"); + for (const o of [0, 4, 8, 12]) key = key.deriveChild(dv.getUint32(o)); + return key; +} +// Sign the challenge (ECDSA over k1, DER-encoded) and call the URL back with +// sig and key. Resolves on the server's OK; throws its reason otherwise. +export async function lnurlAuthLogin(auth, key) { + const sig = secp256k1.sign(hexToBytes(auth.k1), key.privateKey); + const url = new URL(auth.url); + url.searchParams.set('sig', sig.toDERHex()); + url.searchParams.set('key', bytesToHex(key.publicKey)); + const d = await fetchJson(url.href); + if (!d || String(d.status || '').toUpperCase() !== 'OK') throw new Error((d && d.reason) || 'login refused'); + return { key: bytesToHex(key.publicKey) }; +} + +// ---- LNURL-withdraw (LUD-03): the other side asks to PAY us ---- +// Their terms: an amount range (msat), a callback and k1. We make an invoice +// for the amount chosen and hand it to the callback; they pay it. +export function withdrawParamsOf(d) { + if (!d || d.tag !== 'withdrawRequest' || !d.callback || !d.k1) return null; + const min = Math.max(0, Number(d.minWithdrawable) || 0), max = Number(d.maxWithdrawable) || 0; + if (!(max > 0)) return null; + let domain = ''; + try { domain = new URL(d.callback).hostname; } catch {} + return { callback: d.callback, k1: String(d.k1), minMsat: min, maxMsat: Math.max(min, max), description: String(d.defaultDescription || '').slice(0, 200), domain }; +} +export async function fetchWithdrawParams(url) { + const d = await fetchJson(url); + const w = withdrawParamsOf(d); + if (!w) { if (d && d.status === 'ERROR') throw new Error(d.reason || 'LNURL error'); throw new Error('not a Lightning withdraw'); } + return w; +} +// Hand them the invoice to pay. Their OK means they have it, not that it is paid. +export async function submitWithdraw(params, invoice) { + const url = new URL(params.callback); + url.searchParams.set('k1', params.k1); + url.searchParams.set('pr', invoice); + const d = await fetchJson(url.href); + if (!d || String(d.status || '').toUpperCase() !== 'OK') throw new Error((d && d.reason) || 'withdraw refused'); + return true; +} + diff --git a/src/marmot-client.js b/src/marmot-client.js index 4a79b21..b514216 100644 --- a/src/marmot-client.js +++ b/src/marmot-client.js @@ -26,6 +26,7 @@ const DIRECTORY_RELAYS = ['wss://relay.eu.whitenoise.chat', 'wss://relay.us.whit const DAY = 86400; const KP_REFRESH = 30 * DAY; // republish well inside the 84-day lifetime +const RETIRE_CHECK = 6 * 3600; // how often a device asks whether another one took the invitations const REACH_TTL = 10 * 60_000; const now = () => Math.floor(Date.now() / 1000); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -98,6 +99,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY } async function ensure(opts) { await load(); + if (await checkRetired()) return false; // another device took the invitations: ours is not put back const cur = dev && dev.kps && dev.kps.find((k) => k.eventId); if (cur && cur.notAfter - now() > 14 * DAY && now() - cur.at < KP_REFRESH) return true; const s = await signer(opts); @@ -150,6 +152,76 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY return true; } + // ---- which device gets the invitations ---- + // Every device publishes its own KeyPackage, and a Marmot inviter (White + // Noise) picks ONE of them: the invitation lands on whichever device it + // chose, and the others never see the group (Adam, 2026-10-14: the + // phone's). "Only here" retires the account's other packages — a kind 5 + // naming each, NIP-09 `a` for the addressable 30443s and `e` for an old + // 443 — and publishes a fresh one of ours; a device whose package was + // retired sees the deletion (newer than its package) and stops putting + // one back, until "only here" is pressed on it in turn. + const tagOf = (e, k) => (e.tags.find((t) => t[0] === k) || [])[1] || ''; + async function devices() { + await load(); + await checkRetired(); + const own = await ownWriteRelays().catch(() => []); + const where = [...new Set([...own.slice(0, 6), ...PROFILE_RELAYS, ...DIRECTORY_RELAYS])]; + const evs = await queryOn(where, { kinds: [M.KIND.KEY_PACKAGE, 443], authors: [pubkey] }, 3000).catch(() => []); + const bySlot = new Map(); + for (const e of evs) { + if (e.pubkey !== pubkey) continue; + if (e.kind === 443) { bySlot.set('e:' + e.id, { id: e.id, slot: null, created_at: e.created_at, client: tagOf(e, 'client'), legacy: true }); continue; } + const d = tagOf(e, 'd'); + if (!d) continue; + const cur = bySlot.get(d); + if (!cur || e.created_at > cur.created_at) bySlot.set(d, { id: e.id, slot: d, created_at: e.created_at, client: tagOf(e, 'client') }); + } + const mine = dev ? dev.slot : null; + const list = [...bySlot.values()]; + const ours = (x) => !!mine && x.slot === mine; // (an old 443 has no slot: never ours) + return { + slot: mine, retired: !!(dev && dev.retired), published: list.some(ours), + others: list.filter((x) => !ours(x)).sort((a, b) => b.created_at - a.created_at), + }; + } + async function onlyHere(opts = {}) { + await load(); + const s = await signer(opts); + if (!s) throw new Error('no signer'); + if (!dev) { dev = { ...(await M.newDevice(s.sign, pubkey)), kps: [] }; await saveDev(); } + const d = await devices(); + if (d.others.length) { + const tags = [['k', String(M.KIND.KEY_PACKAGE)], ['k', '443']]; + for (const o of d.others) { + tags.push(['e', o.id]); + if (o.slot) tags.push(['a', M.KIND.KEY_PACKAGE + ':' + pubkey + ':' + o.slot]); + } + const ev = await s.sign({ kind: M.KIND.DELETE, created_at: now(), tags, content: 'group invitations go to one device' }); + const own = await ownWriteRelays().catch(() => []); + if (!await publishOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS, ...DIRECTORY_RELAYS])], ev)) throw new Error('not published'); + } + // ours, fresh: newer than any deletion that ever named this slot + if (dev.retired) { dev.retired = false; await saveDev(); } + dev.retireCheckedAt = now(); + if (!await publishKeyPackage(s)) throw new Error('not published'); + return d.others.length; + } + // once in a while: was our package retired on the relays (onlyHere on another device)? + async function checkRetired() { + const cur = dev && dev.kps && dev.kps.find((k) => k.eventId); + if (!dev || dev.retired || !cur || now() - (dev.retireCheckedAt || 0) <= RETIRE_CHECK) return !!(dev && dev.retired); + dev.retireCheckedAt = now(); await saveDev(); + if (await retiredOnRelays(cur)) { dev.retired = true; await saveDev(); } + return dev.retired === true; + } + async function retiredOnRelays(cur) { + const own = await ownWriteRelays().catch(() => []); + const evs = await queryOn([...new Set([...own.slice(0, 6), ...PROFILE_RELAYS])], + { kinds: [M.KIND.DELETE], authors: [pubkey], '#a': [M.KIND.KEY_PACKAGE + ':' + pubkey + ':' + dev.slot] }, 2500).catch(() => []); + return evs.some((e) => e.pubkey === pubkey && e.created_at > (cur.at || 0)); + } + // Somebody's KeyPackages: one per device (a slot is one installation), the // best first, at most ten. An invitation adds every one of them, so a person // with a phone and a laptop gets the group on both — the rule Marmot's @@ -440,7 +512,7 @@ export function marmotClient({ scope, pubkey, identity, on, relays = GROUP_RELAY } return { - pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept, + pubkey, groups, load, start, stop, resubscribe, ready, announce, lookup, lookupAll, siblingAdds, welcome, accept, devices, onlyHere, create, send, add, kick, rename, leave, forget, event: (kind, content, tags) => M.appEvent(pubkey, kind, content, tags), // attachments: seal for the epoch a message is about to go out in, and diff --git a/src/style.css b/src/style.css index f908a05..4a9b191 100644 --- a/src/style.css +++ b/src/style.css @@ -1505,8 +1505,14 @@ body::after { .mp-dots i { width: 7px; height: 7px; border-radius: 50%; background: rgba(255, 255, 255, .38); box-shadow: 0 0 3px rgba(0, 0, 0, .5); transition: background .15s, transform .15s; } .mp-dots i.on { background: #fff; transform: scale(1.15); } .mp-media { max-width: 100%; max-height: 100%; width: 100%; height: 100%; object-fit: contain; display: block; user-select: none; -webkit-user-select: none; } -/* a picture pinches to zoom (attachZoom); one finger at 1× is the pager's swipe */ -.mp-slide img.mp-media { touch-action: pan-x pan-y; transform-origin: center center; will-change: transform; } +/* a picture pinches to zoom (attachZoom); one finger at 1× is the pager's swipe. + Only the slide at rest gets its own layer for that: one on every picture + kept a full-size texture per slide, and a flight past them stuttered. */ +.mp-slide img.mp-media { touch-action: pan-x pan-y; transform-origin: center center; } +.mp-slide.mp-cur img.mp-media { will-change: transform; } +.mp-slide.mp-gone img.mp-media { display: none; } +.mp-missing { display: flex; flex-direction: column; align-items: center; gap: 6px; color: rgba(255, 255, 255, .55); font-size: 15px; text-align: center; padding: 20px; } +.mp-missing-ico { font-size: 44px; opacity: .5; } /* nothing but the picture until a tap */ .mp-meta, .mp-top { opacity: 0; pointer-events: none; transition: opacity .18s; } .media-pager.show .mp-meta, .media-pager.show .mp-top { opacity: 1; pointer-events: auto; } @@ -1535,6 +1541,9 @@ body::after { .mp-counts .i { font-size: 24px; line-height: 1; display: flex; } .mp-counts .i svg { width: 28px; height: 28px; filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .6)); } .mp-like.on { color: #ff4d6d; } +.mp-like .i .cemoji { width: 28px; height: 28px; } +/* the emoji sheet over the media pager (z-index 200) */ +.confirm-pop-backdrop.over-pager { z-index: 300; } .mp-zap.on .i { color: #f7b500; } .mp-top { position: absolute; top: max(10px, env(safe-area-inset-top)); left: 10px; right: 10px; @@ -1546,6 +1555,26 @@ body::after { } .mp-sound { font-size: 15px; } .mp-sound .off { display: none; } +/* A mouse's way through the pager: arrows, where there is a pointer to + hover with (a phone swipes). Faint until the pointer is over the pager; + the sideways pair only when the post has a picture that way. */ +.mp-arrows { display: none; } +@media (hover: hover) and (pointer: fine) { + .mp-arrows { display: contents; } + .mp-arrow { + position: absolute; z-index: 3; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%; + display: flex; align-items: center; justify-content: center; cursor: pointer; + color: #fff; background: rgba(255, 255, 255, .14); opacity: .4; transition: opacity .15s, background .15s; + } + .mp-arrow svg { width: 26px; height: 26px; } + .media-pager:hover .mp-arrow { opacity: .8; } + .mp-arrow:hover { opacity: 1; background: rgba(255, 255, 255, .28); } + .mp-up { right: 14px; top: calc(50% - 50px); } + .mp-down { right: 14px; top: calc(50% + 6px); } + .mp-ar-left { left: 14px; top: calc(50% - 22px); } + .mp-ar-right { right: 72px; top: calc(50% - 22px); } + .media-pager:not(.has-left) .mp-ar-left, .media-pager:not(.has-right) .mp-ar-right { display: none; } +} .media-pager.quiet .mp-sound .on { display: none; } .media-pager.quiet .mp-sound .off { display: inline; } /* muted against our will: the button stays up so the way to sound is plain */ @@ -1632,6 +1661,16 @@ body::after { .mp-seek.drag .mp-seek-time { opacity: 1; } /* the details sit clear of the seek bar */ .media-pager.videos .mp-meta { padding-bottom: calc(var(--mp-seek-lift) + 36px); } +/* A wide window: the caption ran the whole width of the screen and the + counts sat in the far corner (Adam, 1920px across). The words, the + counts and the seek bar keep to a column under the middle of the screen; + the gradient behind them still spans it. */ +@media (min-width: 900px) { + .media-pager { --mp-col: 760px; } + .mp-meta { padding-left: max(14px, calc(50% - var(--mp-col) / 2)); padding-right: max(14px, calc(50% - var(--mp-col) / 2)); } + .mp-seek { left: max(16px, calc(50% - var(--mp-col) / 2)); right: max(16px, calc(50% - var(--mp-col) / 2)); } + .mp-caption { font-size: 15px; } +} /* a clip that waits for a tap: what it costs, over its (blank or still) box */ .vid-heavy { @@ -1774,3 +1813,6 @@ body::after { .chat-compose .chat-box > coinos-text:has(+ .compose-tools > :only-child) { padding-right: 48px; } .chat-compose .chat-box .compose-tools { bottom: 6px; right: 5px; } .compose-tool.chat-clip { width: 34px; } + +/* Always visible, including when the media controls are tucked away. */ +.mp-position { position: absolute; top: calc(env(safe-area-inset-top, 0px) + 16px); left: 50%; transform: translateX(-50%); padding: 5px 10px; border-radius: 20px; background: #0009; color: #fff; font-size: 13px; font-variant-numeric: tabular-nums; pointer-events: none; z-index: 5; } diff --git a/tools/feed-picture-pager-test.js b/tools/feed-picture-pager-test.js index 32625c9..ff69626 100644 --- a/tools/feed-picture-pager-test.js +++ b/tools/feed-picture-pager-test.js @@ -82,12 +82,14 @@ try { await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000)); - console.log('\n[a tap on the picture that opened it closes it]'); + console.log('\n[a tap on the picture that opened it shows its details; the next closes it]'); const y0 = await openPic('two pictures'); check('the picture opens in the pager, on itself', await resting('a1.png'), await curPic()); check('no details until a tap', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('show'))); + await tapPager(); await sleep(400); + check('one tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); await tapPager(); await sleep(600); - check('one tap closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); check('the feed is where it was', Math.abs((await page.evaluate(() => window.scrollY)) - y0) <= 2); console.log('\n[left and right: along a post\'s pictures; up and down: post to post]'); diff --git a/tools/feed-wheel-reverse-test.js b/tools/feed-wheel-reverse-test.js index 8a00b11..632bbc6 100644 --- a/tools/feed-wheel-reverse-test.js +++ b/tools/feed-wheel-reverse-test.js @@ -46,24 +46,29 @@ for (const dir of [1, -1]) { assert((f.y - reversed) * dir < 0, 'next frame continues in the new direction'); assert(Math.abs(f.y - reversed) < 20, 'new velocity is not applied to the old frame delay'); settle(f); - // The turn back is a hunt: a quarter of a notch's travel, not a flight the other way. + // The turn back is a plain notch from rest: its full travel, no flight the other way. const back = (before - f.y) * dir; - assert(back > 65 && back < 90, 'a reverse notch against a coast is a short step: ' + back); - // Each notch on grows the step back towards a full one; the sixth is a plain notch again. - const steps = []; - for (let i = 0; i < 5; i++) { f.advance(200); const y = f.y; f.wheel(-dir); settle(f); steps.push((y - f.y) * dir); } - assert(steps.every((s, i) => i === 0 || s > steps[i - 1] * 1.08), 'hunting steps grow notch by notch: ' + steps.map(Math.round)); - assert(steps[0] > 95 && steps[0] < 125 && steps[3] > 240 && steps[3] < 285 && steps[4] > 280 && steps[4] < 330, 'from about a third of a notch to a full one: ' + steps.map(Math.round)); - { f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 280 && s < 330, 'a few flicks on, the wheel flies again: ' + s); } - // A turn back while hunting starts the small steps over. - f.wheel(-dir); f.advance(17); f.frame(); f.advance(17); f.frame(); - { const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 65 && s < 90, 'a reversal while hunting is a short step again: ' + s); } - { f.advance(300); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 95 && s < 120, 'and the next grows from there: ' + s); } - // A pause ends the hunt: the next notch is the plain push from rest. - { f.advance(2200); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'after a pause a notch is a full push: ' + s); } - // A turn back against a coast that only just died hunts too. - { f.wheel(dir); settle(f); f.advance(200); const y = f.y; f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 65 && s < 90, 'reversing right after the coast dies is a short step: ' + s); } - // ...but a notch from rest the other way, well after, is a full one. + assert(back > 280 && back < 330, 'a reverse notch against a coast travels one notch: ' + back); + // For a moment after a reversal notches don't wind the speed up: a + // reversal and a quick second notch travel about two notches, not a flight. + let calm; + { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame(); // coasting again, the other way + const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); calm = (f.y - y) * dir; + assert(calm > 540 && calm < 700, 'a reversal and a quick second notch: about two notches of travel: ' + calm); } + // From rest, well after, the wind-up is back: the same two quick notches go further. + { f.advance(2500); const y = f.y; f.wheel(dir); f.advance(60); f.frame(); f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > calm + 40, 'two quick notches with the wind-up go further: ' + s + ' vs ' + calm); } + // A spin that keeps going after a reversal winds up again from its third + // notch: a reversal plus four quick notches goes well past five plain ones. + let spin; + { f.wheel(-dir); f.advance(17); f.frame(); f.wheel(-dir); f.advance(120); f.frame(); + const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); spin = (f.y - y) * dir; + assert(spin > 5 * 300 + 150, 'a spin on after a reversal winds up again: ' + spin); } + // ...and the same five quick notches from rest go about as far (the two calm ones cost little). + { f.advance(2500); const y = f.y; f.wheel(dir); for (let i = 0; i < 4; i++) { f.advance(60); f.frame(); f.wheel(dir); } settle(f); const s = (f.y - y) * dir; + assert(s < spin * 1.25, 'five from rest vs five after a reversal: ' + s + ' vs ' + spin); settle(f, 200); } + // From rest a notch either way is a full push. { f.advance(2500); const y = f.y; f.wheel(dir); settle(f); const s = (f.y - y) * dir; assert(s > 280 && s < 330, 'from rest a notch either way is a full push: ' + s); } + // A turn back against a coast that only just died is a reversal too (caught, no wind-up). + { f.wheel(dir); settle(f, 40); f.advance(100); const y = f.y; f.wheel(-dir); f.advance(60); f.frame(); f.wheel(-dir); settle(f); const s = (y - f.y) * dir; assert(s > 520 && s < 700, 'reversing right after the coast dies: no wind-up: ' + s); } } -console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, precision hunt after a reversal (short steps that grow, restart on another turn back, end on a pause)'); +console.log('✓ immediate reversal both ways, stale frame timestamps, busy frame recovery, a reversal is a plain notch, two calm notches, then the wind-up is back'); diff --git a/tools/lnurl-auth-test.js b/tools/lnurl-auth-test.js new file mode 100644 index 0000000..767e85c --- /dev/null +++ b/tools/lnurl-auth-test.js @@ -0,0 +1,102 @@ +// A site's login lnurl (LNURL-auth) pasted into Send is a login, not a +// payment: the wallet asks first, naming the site, then signs the challenge +// and tells the site; the site's refusal is shown as its own words. Against +// a stand-in login server that checks the signature the way a real one does. +// Run: bun tools/lnurl-auth-test.js +import puppeteer from './puppeteer.js'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { hexToBytes } from '@noble/hashes/utils'; +import { encodeLnurl } from '../src/lnurl.js'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const html = await buildHtml({ minify: true, pwa: false }); +const logins = []; // what the stand-in site was told +const server = Bun.serve({ port: 0, fetch: (req) => { + const u = new URL(req.url), p = u.pathname; + if (p === '/lnauth') { + const sig = u.searchParams.get('sig'), key = u.searchParams.get('key'), k1 = u.searchParams.get('k1'); + if (!sig) return Response.json({ status: 'ERROR', reason: 'no sig query variable provided' }); + let good = false; + try { good = secp256k1.verify(secp256k1.Signature.fromDER(sig), hexToBytes(k1), hexToBytes(key)); } catch {} + logins.push({ k1, key, good }); + return Response.json(good ? { status: 'OK' } : { status: 'ERROR', reason: 'bad signature' }); + } + if (p === '/lnauth-closed') return Response.json({ status: 'ERROR', reason: 'registrations are closed' }); + if (p.startsWith('/punks') || p === '/verify-worker.js' || p.startsWith('/locales') || /\.js$/.test(p)) { const f = Bun.file('dist' + p); return f.size ? new Response(f) : new Response('', { status: 404 }); } + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const k1 = 'c0ffee'.repeat(10) + 'abcd'; +const loginLnurl = encodeLnurl(server.url.origin + '/lnauth?tag=login&k1=' + k1 + '&action=login'); +const closedLnurl = encodeLnurl(server.url.origin + '/lnauth-closed?tag=login&k1=' + k1 + '&action=register'); +const browser = await puppeteer.launch({ executablePath: process.env.CHROME || '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const ctx = browser.defaultBrowserContext(); +await ctx.overridePermissions(server.url.origin, ['clipboard-read', 'clipboard-write', 'clipboard-sanitized-write']); +const page = await browser.newPage(); +const errs = []; page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase() === x || n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const text = () => page.evaluate(() => document.body.innerText); +const waitText = async (x, ms = 8000) => { for (let i = 0; i < ms / 200; i++) { if ((await text()).includes(x)) return true; await sleep(200); } return false; }; +const freshSend = async () => { + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Receive')?.click()); await sleep(300); + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Send')?.click()); + await page.waitForSelector('textarea.send-dest', { timeout: 10000 }); + await page.$eval('textarea.send-dest', (e) => { e.value = ''; e.dispatchEvent(new Event('input', { bubbles: true })); }); await sleep(300); + await page.focus('textarea.send-dest'); +}; +const paste = async (s) => { await page.evaluate((v) => navigator.clipboard.writeText(v), s); await page.keyboard.down('Control'); await page.keyboard.press('KeyV'); await page.keyboard.up('Control'); }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(500); await click('to import'); await sleep(400); + await page.waitForSelector('textarea'); await page.type('textarea', generateMnemonic(wordlist)); await click('open wallet'); + for (let i = 0; i < 80 && !(await page.evaluate(() => [...document.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Send'))); i++) { + await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /^(not now|skip|continue|later)$/i.test(b.textContent.trim()))?.click()); + await sleep(400); + } + await sleep(3000); + const host = new URL(server.url.origin).hostname; + + console.log('[a login lnurl pasted into Send]'); + await freshSend(); + await paste(loginLnurl); + check('the wallet asks first, naming the site', await waitText('Log in with Lightning') && (await text()).includes(host + ' is asking you to log in'), (await text()).slice(0, 200)); + check('…says what it is about to do, and that nothing is paid', /key it keeps for this site alone/.test(await text()) && /No payment is made/.test(await text())); + check('…no amount field, no payment flow', !(await page.$('input[type=number]'))); + check('nothing was sent to the site before the user agreed', logins.length === 0); + await click('log in'); + check('the site accepts the signed challenge', await waitText('Logged in to ' + host, 8000), (await text()).slice(0, 300)); + check('…it was told once, with a signature that verifies under the key', logins.length === 1 && logins[0].good && logins[0].k1 === k1, JSON.stringify(logins)); + const key1 = logins[0].key; + await click('done'); + check('Done returns to a blank Send form', await page.waitForSelector('textarea.send-dest', { timeout: 5000 }).then(() => true, () => false) && (await page.$eval('textarea.send-dest', (e) => e.value)) === ''); + + console.log('[the same site again: the same key]'); + await freshSend(); + await paste(loginLnurl); + await waitText('Log in with Lightning'); await click('log in'); + await waitText('Logged in to ' + host, 8000); + check('the wallet is the same user on the site next time', logins.length === 2 && logins[1].key === key1, JSON.stringify(logins.map((l) => l.key.slice(0, 12)))); + await click('done'); + + console.log('[a site that says no]'); + await freshSend(); + await paste(closedLnurl); + check('a register link asks to create an account', await waitText('asking you to create an account')); + await click('create account'); + check("the site's reason is shown", await waitText('registrations are closed', 8000), (await text()).slice(0, 300)); + check('…with a way to try again, and back', (await text()).includes('Try again') && (await text()).includes('Back')); + await click('back'); + check('Back leaves the login', await page.waitForSelector('textarea.send-dest', { timeout: 5000 }).then(() => true, () => false)); + check('no page errors', errs.length === 0, errs.join('; ').slice(0, 300)); +} catch (e) { + check('run completed', false, e.stack); +} finally { + await browser.close(); server.stop(true); +} +console.log(ok ? '\n✅ a login lnurl logs in' : '\n❌ failures above'); +process.exit(ok ? 0 : 1); diff --git a/tools/lnurl-auth-unit-test.js b/tools/lnurl-auth-unit-test.js new file mode 100644 index 0000000..c9989be --- /dev/null +++ b/tools/lnurl-auth-unit-test.js @@ -0,0 +1,63 @@ +// LNURL-auth (LUD-04/05) without a browser: a login lnurl is told from a +// pay one; the linking key is the same for a domain every time and different +// per domain; the signature over k1 verifies under the key sent. +// Run: bun tools/lnurl-auth-unit-test.js +import assert from 'node:assert/strict'; +import { HDKey } from '@scure/bip32'; +import { mnemonicToSeedSync } from '@scure/bip39'; +import { secp256k1 } from '@noble/curves/secp256k1'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { encodeLnurl, parseLnurlAuth, lnurlAuthKey, lnurlAuthLogin } from '../src/lnurl.js'; +const k1 = 'a'.repeat(64); +const loginUrl = 'https://stacker.news/api/lnauth?tag=login&k1=' + k1 + '&action=login'; +const a = parseLnurlAuth(encodeLnurl(loginUrl)); +assert.deepEqual(a, { url: loginUrl, domain: 'stacker.news', k1, action: 'login' }); +assert.equal(parseLnurlAuth('lightning:' + encodeLnurl(loginUrl).toUpperCase()).domain, 'stacker.news', 'a lightning: prefix and upper case are fine'); +assert.equal(parseLnurlAuth(encodeLnurl('https://stacker.news/api/lnauth?tag=login&k1=zz')), null, 'a k1 that is not 32 bytes is not a login'); +assert.equal(parseLnurlAuth(encodeLnurl('https://coinos.io/.well-known/lnurlp/adam')), null, 'a pay lnurl is not a login'); +assert.equal(parseLnurlAuth('[email protected]'), null); +assert.equal(parseLnurlAuth(encodeLnurl(loginUrl.replace('action=login', 'action=register'))).action, 'register'); +assert.equal(parseLnurlAuth(encodeLnurl(loginUrl.replace('action=login', 'action=what'))).action, 'login', 'an unknown action is a login'); +const master = HDKey.fromMasterSeed(mnemonicToSeedSync('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about')); +const k = lnurlAuthKey(master, 'stacker.news'); +assert.equal(bytesToHex(k.publicKey), bytesToHex(lnurlAuthKey(master, 'stacker.news').publicKey), 'the same key for a domain every time'); +assert.notEqual(bytesToHex(k.publicKey), bytesToHex(lnurlAuthKey(master, 'coinos.io').publicKey), 'another domain, another key'); +assert.equal(k.publicKey.length, 33, 'a compressed public key'); +assert.notEqual(bytesToHex(lnurlAuthKey(HDKey.fromMasterSeed(mnemonicToSeedSync('zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong')), 'stacker.news').publicKey), bytesToHex(k.publicKey), 'another wallet, another user'); +// the callback, with a server that checks the signature the way theirs does +let got = null; +globalThis.fetch = async (url) => { + const u = new URL(url); + got = { sig: u.searchParams.get('sig'), key: u.searchParams.get('key'), k1: u.searchParams.get('k1'), tag: u.searchParams.get('tag') }; + const okSig = secp256k1.verify(secp256k1.Signature.fromDER(got.sig), hexToBytes(got.k1), hexToBytes(got.key)); + return new Response(JSON.stringify(okSig ? { status: 'OK' } : { status: 'ERROR', reason: 'bad sig' }), { headers: { 'content-type': 'application/json' } }); +}; +const r = await lnurlAuthLogin(a, k); +assert.equal(got.tag, 'login'); assert.equal(got.k1, k1); +assert.equal(got.key, bytesToHex(k.publicKey), 'the key sent is the linking key'); +assert.equal(r.key, got.key); +assert.match(got.sig, /^30[0-9a-f]+$/, 'a DER signature'); +globalThis.fetch = async () => new Response(JSON.stringify({ status: 'ERROR', reason: 'no such user' }), { headers: { 'content-type': 'application/json' } }); +await assert.rejects(lnurlAuthLogin(a, k), /no such user/, "the server's reason is the error"); +console.log('✓ lnurl-auth: a login is told from a payment, one key per site per wallet, the signature verifies, the server has the last word'); + +// ---- LNURL-withdraw (LUD-03) ---- +const { parseZapTarget, withdrawParamsOf, fetchWithdrawParams, submitWithdraw, fetchPayParams } = await import('../src/lnurl.js'); +const w = parseZapTarget('lnurlw://pay.example.com/withdraw?k=1'); +assert.equal(w.url, 'https://pay.example.com/withdraw?k=1', 'lnurlw:// is the URL over https'); +assert.equal(parseZapTarget('LIGHTNING:lnurlp://pay.example.com/.well-known/lnurlp/bob').url, 'https://pay.example.com/.well-known/lnurlp/bob'); +assert.equal(parseZapTarget('lnurlw://abc.onion/w').url, 'http://abc.onion/w', 'an onion over http'); +const terms = { tag: 'withdrawRequest', callback: 'https://pay.example.com/cb', k1: 'k1k1', minWithdrawable: 1000, maxWithdrawable: 50000, defaultDescription: 'faucet' }; +assert.deepEqual(withdrawParamsOf(terms), { callback: 'https://pay.example.com/cb', k1: 'k1k1', minMsat: 1000, maxMsat: 50000, description: 'faucet', domain: 'pay.example.com' }); +assert.equal(withdrawParamsOf({ tag: 'payRequest', callback: 'x' }), null); +assert.equal(withdrawParamsOf({ ...terms, maxWithdrawable: 0 }), null, 'nothing to withdraw is no withdraw'); +globalThis.fetch = async () => Response.json(terms); +assert.deepEqual(await fetchWithdrawParams('https://pay.example.com/w'), withdrawParamsOf(terms)); +await assert.rejects(fetchPayParams('https://pay.example.com/w'), (e) => e.withdraw && e.withdraw.k1 === 'k1k1', 'a pay fetch that finds a withdraw says so'); +let cb = null; +globalThis.fetch = async (url) => { const u = new URL(url); cb = { path: u.pathname, k1: u.searchParams.get('k1'), pr: u.searchParams.get('pr') }; return Response.json({ status: 'OK' }); }; +assert.equal(await submitWithdraw(withdrawParamsOf(terms), 'lnbc1invoice'), true); +assert.deepEqual(cb, { path: '/cb', k1: 'k1k1', pr: 'lnbc1invoice' }, 'the callback gets k1 and the invoice'); +globalThis.fetch = async () => Response.json({ status: 'ERROR', reason: 'already claimed' }); +await assert.rejects(submitWithdraw(withdrawParamsOf(terms), 'lnbc1invoice'), /already claimed/); +console.log('✓ lnurl-withdraw: lnurlw:// and lnurlp:// schemes, the terms, a pay fetch that finds a withdraw, the callback, the refusal'); diff --git a/tools/lnurl-withdraw-test.js b/tools/lnurl-withdraw-test.js new file mode 100644 index 0000000..cc1b152 --- /dev/null +++ b/tools/lnurl-withdraw-test.js @@ -0,0 +1,103 @@ +// LNURL-withdraw: a site's withdraw lnurl pasted into Send is an offer to +// PAY us — the wallet shows their range, makes an invoice for the amount +// chosen, hands it to their callback and watches for the money. The Ark +// side is faked (the invoice hook); the site is a stand-in on the test +// server. Run: bun tools/lnurl-withdraw-test.js +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +import { encodeLnurl } from '../src/lnurl.js'; + +const app = await Bun.file('src/app.js').text(); +const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { zapsFeature } from './src/features/zaps.js'; +${domHelper} +const ui = { screen: 'wallet', tab: 'send', send: { recipients: [{ address: '', amount: '' }] } }; +let watch = null, cancelled = [], invoices = 0; +const wallet = { watchOnly: false, loadFeatureState: (k, d) => d, saveFeatureState: () => {} }; +const ctx = { + h, ui, wallet, render, toast: (m) => (window.toasts = (window.toasts || [])).push(m), + blankSend: () => ({ recipients: [{ address: '', amount: '' }] }), + fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => h('span', { class: 'unit-tag' }, 'sats'), + parseAmount: (v) => { const n = Number(String(v).replace(/,/g, '')); return isFinite(n) ? Math.round(n) : null; }, getUnit: () => 'sats', + amountField: (sat) => String(sat), + hook(name, ...a) { + if (name === 'canLnPay') return true; + if (name === 'arkLnInvoice') { invoices++; return Promise.resolve({ id: 'ln' + invoices, invoice: 'lnbc' + a[0] + 'n1testinvoice' + invoices, amountSat: a[0] }); } + if (name === 'arkLnWatch') { watch = { id: a[0], cb: a[1] }; return true; } + if (name === 'arkLnUnwatch') { if (watch && watch.id === a[0]) watch = null; return true; } + if (name === 'arkLnCancel') { cancelled.push(a[0]); return Promise.resolve(true); } + return null; + }, +}; +const feature = zapsFeature(ctx); window.__feature = feature; +function render() { document.querySelector('#app').replaceChildren(feature.sendView() || h('div', { id: 'closed' }, 'closed')); } +window.test = { paste: (s) => feature.matchSendText(s, false), settle: (ok) => { const w = watch; watch = null; w.cb({ step: ok ? 'done' : 'failed' }); }, get watch() { return watch && watch.id; }, get cancelled() { return cancelled; }, ui }; +render(); +`; +const bundle = await Bun.build({ + entrypoints: ['lnw-test-entry'], target: 'browser', + plugins: [{ name: 'lnw-test', setup(build) { + build.onResolve({ filter: /^lnw-test-entry$/ }, () => ({ path: 'entry', namespace: 'lnw-test' })); + build.onLoad({ filter: /.*/, namespace: 'lnw-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + } }], +}); +assert(bundle.success, bundle.logs.join('\n')); +const css = await Bun.file('src/style.css').text(); +const js = await bundle.outputs[0].text(); +const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}</style><div id="app"></div><script type="module">${js}</script>`; +const claims = []; +const server = Bun.serve({ port: 0, fetch: (req) => { + const u = new URL(req.url); + if (u.pathname === '/withdraw') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb', k1: 'deadbeef', minWithdrawable: 100000, maxWithdrawable: 5000000, defaultDescription: 'Faucet drip' }); + if (u.pathname === '/withdraw-fixed') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb', k1: 'fixed1', minWithdrawable: 21000, maxWithdrawable: 21000 }); + if (u.pathname === '/withdraw-spent') return Response.json({ tag: 'withdrawRequest', callback: server.url.origin + '/cb-spent', k1: 'spent', minWithdrawable: 1000, maxWithdrawable: 1000 }); + if (u.pathname === '/cb') { claims.push({ k1: u.searchParams.get('k1'), pr: u.searchParams.get('pr') }); return Response.json({ status: 'OK' }); } + if (u.pathname === '/cb-spent') return Response.json({ status: 'ERROR', reason: 'this voucher was already used' }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const lnurl = (path) => encodeLnurl(server.url.origin + path); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +const pause = (ms) => new Promise((r) => setTimeout(r, ms)); +const check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); console.log(' ✓ ' + name); }; +const text = () => page.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +const waitText = async (re, ms = 5000) => { for (let i = 0; i < ms / 100; i++) { if (re.test(await text())) return true; await pause(100); } return false; }; +const click = (label) => page.evaluate((x) => { const b = [...document.querySelectorAll('button')].find((n) => n.textContent.trim() === x || n.textContent.trim().endsWith(' ' + x)); if (!b) return false; b.click(); return true; }, label); +const type = (sel, v) => page.evaluate(([s, val]) => { const i = document.querySelector(s); i.value = val; i.dispatchEvent(new Event('input', { bubbles: true })); }, [sel, v]); +const host = new URL(server.url.origin).hostname; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href); + await page.waitForSelector('#closed'); + console.log('[a withdraw lnurl with a range]'); + check('the paste is claimed', await page.evaluate((s) => test.paste(s), lnurl('/withdraw'))); + check('their terms show: the site, the range, the description', await waitText(/Withdraw over Lightning/) && new RegExp(host + ' will pay you between 100 and 5,000 sats').test(await text()) && /Faucet drip/.test(await text()), await text()); + check('the amount starts at their max', await page.$eval('input[type=number]', (i) => i.value) === '5000'); + await type('input[type=number]', '9000'); await click('Withdraw'); await pause(100); + check('over their max: told the range, nothing sent', /Between 100 and 5,000 sats/.test(await text()) && claims.length === 0); + await type('input[type=number]', '1500'); await click('Withdraw'); + check('an invoice for the amount is handed to their callback', await waitText(new RegExp(host + ' has your invoice for 1,500 sats')) && claims.length === 1 && claims[0].k1 === 'deadbeef' && claims[0].pr === 'lnbc1500n1testinvoice1', JSON.stringify(claims)); + check('…and the wallet watches for the money', (await page.evaluate(() => test.watch)) === 'ln1'); + await page.evaluate(() => test.settle(true)); + check('paid: Received', await waitText(/Received 1,500 sats/) && !!(await page.$('.check-badge'))); + await click('Done'); await pause(50); + check('Done closes it', !!(await page.$('#closed'))); + + console.log('[a fixed amount]'); + await page.evaluate((s) => test.paste(s), lnurl('/withdraw-fixed')); + check('a fixed amount is stated, with no field', await waitText(new RegExp(host + ' will pay you 21 sats')) && !(await page.$('input[type=number]'))); + await click('Withdraw'); + check('withdrawn at that amount', await waitText(/has your invoice for 21 sats/) && claims[1].pr === 'lnbc21n1testinvoice2', JSON.stringify(claims)); + await click('Back'); await pause(50); + check('Back before it is paid stops watching', (await page.evaluate(() => test.watch)) === null && !!(await page.$('#closed'))); + + console.log('[a site that refuses]'); + await page.evaluate((s) => test.paste(s), lnurl('/withdraw-spent')); + await waitText(/will pay you 1 sats/); await click('Withdraw'); + check("their reason is shown, with Try again", await waitText(/already used/) && /Try again/.test(await text()), await text()); + check('no page errors', errors.length === 0, errors.join('; ')); + console.log('\n✅ a withdraw lnurl pays us'); +} catch (e) { console.error('\n❌', e.message); process.exitCode = 1; } finally { await browser.close(); server.stop(true); } diff --git a/tools/pager-controls-test.js b/tools/pager-controls-test.js new file mode 100644 index 0000000..a02591d --- /dev/null +++ b/tools/pager-controls-test.js @@ -0,0 +1,114 @@ +// The media pager's buttons under a finger: the sound button mutes on ONE +// tap (the browser's click and the pager's pending tap both clicked it, so +// it toggled twice and a clip could not be muted — Adam, 2026-10-16), and +// the reaction button offers the emoji sheet, over the pager, as the feed's +// does; the emoji picked shows on the button, and a tap again takes it back. +// Run: bun tools/pager-controls-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +import { existsSync } from 'node:fs'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const clip = (name, size) => { + const f = '/tmp/coinos-vpager-' + name + '.webm'; + if (!existsSync(f)) Bun.spawnSync(['ffmpeg', '-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size + ':rate=10', '-t', '30', '-c:v', 'libvpx', '-b:v', '100k', f]); + return f; +}; +const CLIPS = { tall: clip('tall', '180x320') }; +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path === '/tall.webm') return new Response(Bun.file(CLIPS.tall), { headers: { 'content-type': 'video/webm' } }); + if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path)); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const seeded = [post('a tall one ' + server.url.origin + '/tall.webm', 100), + ...Array.from({ length: 8 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 500 + i * 60))]; +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--autoplay-policy=no-user-gesture-required'] }); +const page = await browser.newPage(); +await page.evaluateOnNewDocument(() => { + const Real = window.WebSocket; + window.WebSocket = function (...a) { const ws = new Real(...a); const send = ws.send.bind(ws); ws.send = (d) => { try { const m = JSON.parse(d); if (m[0] === 'REQ') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {} return send(d); }; return ws; }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const centre = (sel) => page.evaluate((s) => { const r = document.querySelector(s).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }, sel); +const tap = async (sel) => { const c = await centre(sel); await page.touchscreen.tap(c.x, c.y); await sleep(450); }; // (past the pending tap's 120 ms) +const sound = () => page.evaluate(() => { const v = document.querySelector('.mp-slide video.mp-media'); return { quiet: document.querySelector('.media-pager').classList.contains('quiet'), muted: v ? v.muted : null }; }); +const likeBtn = () => page.evaluate(() => { const b = document.querySelector('.mp-like'); return { on: b.classList.contains('on'), text: b.querySelector('.i').textContent.trim(), heart: !!b.querySelector('.i svg') }; }); + +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Clip Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the clip is in the feed', await waitFor(() => !!document.querySelector('.notes-feed video.note-video'), null, 20000)); + await page.evaluate(() => document.querySelector('.notes-feed video.note-video').scrollIntoView({ block: 'center' })); + await sleep(500); + const at = await page.evaluate(() => { const b = document.querySelector('.notes-feed video.note-video').getBoundingClientRect(); return { x: b.x + b.width * 0.2, y: b.y + b.height * 0.3 }; }); + await page.touchscreen.tap(at.x, at.y); + check('the clip opens in the pager', await waitFor(() => !!document.querySelector('.media-pager.videos .mp-slide video.mp-media'), null, 6000)); + await sleep(1000); // (a click within 800 ms of the touch that opened it is that touch's) + // the buttons sit in the details, up on a tap + await tap('.mp-slide video.mp-media'); + check('a tap brings the details up', await waitFor(() => document.querySelector('.media-pager').classList.contains('show'), null, 2000)); + + console.log('\n[the sound button: one tap, one change]'); + const s0 = await sound(); + check('it starts loud', !s0.quiet && s0.muted === false, JSON.stringify(s0)); + await tap('.mp-sound'); + let s = await sound(); + check('one tap mutes it — and it stays muted', s.quiet && s.muted === true, JSON.stringify(s)); + await sleep(500); s = await sound(); + check('…still muted half a second on', s.quiet && s.muted === true, JSON.stringify(s)); + await tap('.mp-sound'); + s = await sound(); + check('a tap again unmutes', !s.quiet && s.muted === false, JSON.stringify(s)); + + console.log('\n[the reaction button: a menu of emoji, over the pager]'); + check('the details are still up', await page.evaluate(() => document.querySelector('.media-pager').classList.contains('show'))); + await tap('.mp-like'); + check('a tap opens the emoji sheet', await waitFor(() => !!document.querySelector('.confirm-pop-backdrop.over-pager .msg-sheet-emojis'), null, 3000)); + check('…on top of the pager', await page.evaluate(() => { const b = document.querySelector('.msg-sheet-emojis button'); const r = b.getBoundingClientRect(); const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return !!hit && !!hit.closest('.msg-sheet-emojis'); })); + check('…with several to pick from, and a way to more', await page.evaluate(() => document.querySelectorAll('.msg-sheet-emojis button').length >= 5 && !!document.querySelector('.msg-sheet-emojis .more'))); + check('the pager is still there beneath', await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); + await page.evaluate(() => [...document.querySelectorAll('.msg-sheet-emojis button')].find((b) => b.textContent.trim() === '🔥').click()); + check('picking 🔥 closes the sheet', await waitFor(() => !document.querySelector('.confirm-pop-backdrop.over-pager'), null, 3000)); + check('…and the button shows 🔥, lit', await waitFor(() => { const b = document.querySelector('.mp-like'); return b.classList.contains('on') && b.querySelector('.i').textContent.trim() === '🔥'; }, null, 4000), JSON.stringify(await likeBtn())); + check('…still in the pager', await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); + await tap('.mp-like'); + check('a tap again takes the reaction back: the heart, unlit', await waitFor(() => { const b = document.querySelector('.mp-like'); return !b.classList.contains('on') && !!b.querySelector('.i svg'); }, null, 4000), JSON.stringify(await likeBtn())); + await tap('.mp-like'); + await waitFor(() => !!document.querySelector('.confirm-pop-backdrop.over-pager'), null, 3000); + await page.keyboard.press('Escape'); + check('Escape closes the sheet, not the pager', await waitFor(() => !document.querySelector('.confirm-pop-backdrop.over-pager'), null, 2000) && await page.evaluate(() => !!document.querySelector('.media-pager.videos'))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the pager\'s buttons under a finger' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/pager-gone-picture-test.js b/tools/pager-gone-picture-test.js new file mode 100644 index 0000000..683493b --- /dev/null +++ b/tools/pager-gone-picture-test.js @@ -0,0 +1,77 @@ +// A picture whose host is down (its server answers 502, so neither a copy +// nor the original can load) shows a plain "couldn't be loaded" slide with +// the host's name, not the browser's broken-picture glyph in a corner +// (Adam's screenshot, 2026-10-16: isolabellart's relay down). The slides +// around it are untouched. Run: bun tools/pager-gone-picture-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const PICS = { big1: png('2400x1800'), big3: png('2000x2000') }; +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const html = await buildHtml({ minify: true, pwa: false }); +const hits = []; +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + if (path === '/big2.png') { hits.push('big2'); return new Response('Bad Gateway', { status: 502 }); } // the host is down + const m = /^\/(big\d)\.png$/.exec(path); + if (m) { hits.push(m[1]); return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); } + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const seeded = [post('first big ' + U('big1'), 100), post('second big ' + U('big2'), 200), post('third big ' + U('big3'), 300), + ...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 500 + i * 60))]; +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const slides = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const i = s.querySelector('img.mp-media'); const r = s.getBoundingClientRect(); return { k: s.getAttribute('data-k').split('/').pop(), src: (i.getAttribute('src') || '').replace(/^blob:.*/, 'blob:'), cur: s.classList.contains('mp-cur'), willChange: getComputedStyle(i).willChange, on: Math.abs(r.top) < 4 }; })); +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the feed paints its copies of the pictures', await waitFor(() => [...document.querySelectorAll('.notes-feed img.note-img')].filter((i) => i.getAttribute('src').startsWith('blob:')).length >= 2, null, 25000)); + const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('first big')); r.scrollIntoView({ block: 'center' }); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }); + await sleep(300); + await page.mouse.click(at.x, at.y); + check('the picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager .mp-slide img.mp-media'), null, 4000)); + check('the picture that loads is sharpened at rest', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur img.mp-media'); return c && c.getAttribute('src').endsWith('/big1.png'); }, null, 5000)); + await page.keyboard.press('ArrowDown'); + check('down: the dead picture\'s slide', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big2.png'); }, null, 4000)); + check('it says the picture couldn\'t be loaded, naming the host', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c.classList.contains('mp-gone') && /couldn’t be loaded/.test(c.textContent) && /localhost/.test(c.querySelector('.mp-missing')?.textContent || ''); }, null, 6000), await page.evaluate(() => document.querySelector('.mp-slide.mp-cur').outerHTML.slice(0, 300))); + check('…and the broken <img> is hidden', await page.evaluate(() => getComputedStyle(document.querySelector('.mp-slide.mp-cur img.mp-media')).display === 'none')); + check('the original was tried after the copy failed', await page.evaluate(() => document.querySelector('.mp-slide.mp-cur img.mp-media').getAttribute('src').endsWith('/big2.png'))); + await page.keyboard.press('ArrowDown'); + check('the next slide is fine', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big3.png') && !c.classList.contains('mp-gone'); }, null, 4000) && await waitFor(() => document.querySelector('.mp-slide.mp-cur img.mp-media').getAttribute('src').endsWith('/big3.png'), null, 5000)); + check('its caption and controls are still there on the dead one', await page.evaluate(() => { const g = [...document.querySelectorAll('.mp-slide')].find((x) => x.getAttribute('data-k').endsWith('/big2.png')); return !!g.querySelector('.mp-like') && /second big/.test(g.textContent); })); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a dead picture says so' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/pager-loading-test.js b/tools/pager-loading-test.js new file mode 100644 index 0000000..76e2764 --- /dev/null +++ b/tools/pager-loading-test.js @@ -0,0 +1,82 @@ +// Exercise the production refill scheduler with delayed and sparse sources. +// Run: bun tools/pager-loading-test.js +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import assert from 'node:assert/strict'; + +const source = readFileSync(new URL('../src/features/messages.js', import.meta.url), 'utf8'); +const scheduler = source.slice(source.indexOf(' const PAGER_INITIAL ='), source.indexOf(' // Slides for feed items', source.indexOf(' const PAGER_INITIAL ='))); +function fixture(kind = 'images', grid = false) { + let clock = 100000, calls = 0, pending = null, count = 3; + const groups = Array.from({ length: 300 }, () => ({})); + const cache = { shown: 3, notes: Array(3), stopped: false }; + const index = { until: 10000, images: new Map(), videos: new Map() }; + const p = { kind, cur: { closest: () => groups[0] } }; + const more = async () => { + calls++; + if (pending) await pending; + count += 10; + cache.shown += 10; cache.notes.length += 10; + index.until -= 100; + }; + const src = { c: cache, more, done: () => false }; + const ctx = vm.createContext({ + pager: p, pagerGroups: () => groups.slice(0, count), + pagerSource: () => grid ? null : src, feedNow: () => cache, mediaIndex: () => index, feedDef: () => ({}), + crawlMedia: (c, k) => { assert.equal(c, cache); assert.equal(k, kind); return more(); }, + growPager: () => {}, Date: { now: () => clock }, + }); + vm.runInContext(scheduler, ctx); + return { + ctx, p, src, cache, index, load: () => ctx.pagerLoadAhead(), + go: (i) => { p.cur = { closest: () => groups[i] }; }, + advance: () => { clock += 11000; }, + delay: () => { let resolve; pending = new Promise((r) => { resolve = r; }); return () => { pending = null; resolve(); }; }, + count: () => count, calls: () => calls, + }; +} + +for (const grid of [false, true]) for (const kind of ['images', 'videos']) { + const f = fixture(kind, grid); + await f.load(); + assert.equal(f.calls(), 3, 'opening starts loading immediately to at least 30 posts'); + f.advance(); await f.load(); + assert.equal(f.calls(), 3, 'a full buffer does not download forever'); + f.go(29); f.advance(); + const finish = f.delay(); + const loading = f.load(); + assert.equal(f.calls(), 4, 'refill starts well before the end'); + f.go(0); await f.load(); + assert.equal(f.calls(), 4, 'wrapping during a request does not start a concurrent one'); + finish(); await loading; + assert.equal(f.count(), 53, 'refill continues after wrapping, keeping 20 beyond the furthest post'); + console.log('✓ early loading and wrap refill:', grid ? 'grid' : 'feed/profile', kind); +} +{ + const f = fixture(); + f.src.more = async () => { f.cache.notes.length++; }; + await f.load(); + assert.equal(f.cache.notes.length, 11, 'sparse results are limited to eight requests per run'); + f.advance(); await f.load(); + assert.equal(f.cache.notes.length, 19, 'timer can resume sparse results from the first post'); +} +{ + const f = fixture(); + let calls = 0; + f.src.more = async () => { calls++; }; + await f.load(); await f.load(); + assert.equal(calls, 1, 'an empty result backs off'); + f.advance(); await f.load(); + assert.equal(calls, 2, 'an empty source can retry'); + f.src.done = () => true; + f.advance(); await f.load(); + assert.equal(calls, 2, 'an exhausted source is not requested'); +} +{ + const f = fixture(); + const finish = f.delay(), loading = f.load(); + f.ctx.pager = { kind: 'videos' }; + finish(); await loading; + assert.equal(f.calls(), 1, 'closing or switching pagers stops the old refill'); +} +console.log('✓ sparse sources, backoff, exhaustion, and pager lifecycle'); diff --git a/tools/pager-resize-test.js b/tools/pager-resize-test.js new file mode 100644 index 0000000..07fcbba --- /dev/null +++ b/tools/pager-resize-test.js @@ -0,0 +1,134 @@ +// The pager keeps its post when the screen changes size: the page going +// full screen for it (a taller window, the address bar gone), or a turn of +// the phone. Posts are a screen tall, so a pixel scroll position deep in +// the pager lands in another post after a resize — Adam's full-screen +// button on a clip 23 posts down showed him another post's picture. The +// resize can come before the observer has even named the first slide. +// Run: bun tools/pager-resize-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), e: png('240x180') }; + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + const m = /^\/(a1|a2|b|d|e)\d*\.png$/.exec(path); + if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const seeded = [ + post('two pictures ' + U('a1') + ' ' + U('a2'), 100), + post('one picture ' + U('b'), 200), + post('only words here, nothing to look at', 300), + post('a last picture ' + U('d'), 400), + ...Array.from({ length: 12 }, (_, i) => post('more ' + i + ' ' + U('e' + i), 500 + i * 60)), // a dozen more, one picture each, for a flight +]; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4'] /* a pointer that hovers: headless has none */ }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +// the slide the pager rests on, by its picture +const curPic = () => page.evaluate(() => { + const sc = document.querySelector('.mp-scroll'); if (!sc) return null; + const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); + return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between'; +}); +const resting = async (name) => rested(name).then(async (r) => { await sleep(260); return r; }); // a breath after: the wheel's gate between steps +const rested = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000); +const openPic = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, word); + await sleep(400); + const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2, y0: window.scrollY }; }, word); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager'), null, 4000); + return at.y0; +}; +const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); }; +const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); + +try { + await page.setViewport({ width: 1280, height: 900 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000)); + const cdp = await page.target().createCDPSession(); + // a trackpad's run: small deltas, page-side — a CDP mouseWheel is always a 120-unit notch to Chrome + const pad = (dy, n) => page.evaluate(async ([dy, n]) => { const sc = document.querySelector('.mp-scroll'); for (let i = 0; i < n; i++) { const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const ev = new WheelEvent('wheel', { deltaY: dy, deltaX: 0, deltaMode: 0, bubbles: true, cancelable: true, clientX: 640, clientY: 450 }); Object.defineProperty(ev, 'wheelDeltaY', { value: -Math.round(dy * 1.2) }); s.querySelector('img.mp-media').dispatchEvent(ev); await new Promise((r) => setTimeout(r, 16)); } }, [dy, n]); + const pos = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); return sc ? Math.round(sc.scrollTop) + '/' + sc.clientHeight + ' glide:' + !!sc._glide + ' snap:' + sc.style.scrollSnapType : 'none'; }); + const wheel = (dy, { dx = 0, mods = 0 } = {}) => cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 640, y: 450, deltaX: dx, deltaY: dy, modifiers: mods }); + const zoomOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /scale\(([\d.]+)\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? Number(m[1]) : 1; }); + const shiftOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? [Number(m[1]), Number(m[2])] : [0, 0]; }); + + console.log('\n[the screen grows right as the pager opens]'); + // the feed draws a window of rows: scroll down until the post is drawn + for (let i = 0; i < 40; i++) { + if (await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); if (!r) return false; r.scrollIntoView({ block: 'center' }); return true; }, 'more 8')) break; + await page.evaluate(() => window.scrollBy(0, 1500)); await sleep(300); + } + await sleep(500); + const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }, 'more 8'); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager'), null, 4000); + // the window grows (full screen) before the pager's observer has named a slide + await page.setViewport({ width: 1280, height: 1040 }); + check('the pager rests on the picture tapped, eleven posts down', await resting('e8.png'), await curPic() + ' ' + await pos()); + await sleep(600); + check('…and stays there', (await curPic()) === 'e8.png', await curPic()); + console.log('\n[a later resize]'); + await page.keyboard.press('ArrowDown'); await resting('e9.png'); + await page.setViewport({ width: 1280, height: 780 }); + check('the window shrinks: still on the post the reader moved to', await resting('e9.png'), await curPic() + ' ' + await pos()); + await page.setViewport({ width: 1280, height: 1100 }); + check('…and grows again: still there', await resting('e9.png'), await curPic() + ' ' + await pos()); + await page.keyboard.press('ArrowUp'); await resting('e8.png'); + await page.setViewport({ width: 900, height: 1200 }); + check('a different shape: still on the post', await resting('e8.png'), await curPic() + ' ' + await pos()); + console.log('\n[a wide window: the words and counts keep to the middle]'); + await page.setViewport({ width: 1920, height: 600 }); + await resting('e8.png'); + await page.keyboard.press('ArrowDown'); await resting('e9.png'); // moved on from the start: a tap shows the details (on the start it closes) + await page.mouse.click(960, 300); await sleep(400); + const geo = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const r = (q) => { const e = s.querySelector(q); const b = e.getBoundingClientRect(); return [Math.round(b.left), Math.round(b.right)]; }; return { show: document.querySelector('.media-pager').classList.contains('show'), left: r('.mp-left'), counts: r('.mp-counts'), meta: r('.mp-meta') }; }); + check('the details are up', geo.show); + check('the words start near the middle column, not the screen\'s edge', geo.left[0] >= 560 && geo.left[0] <= 600, JSON.stringify(geo)); + check('the counts sit at the column\'s edge, not the screen\'s corner', geo.counts[1] >= 1320 && geo.counts[1] <= 1360, JSON.stringify(geo)); + check('the gradient still spans the screen', geo.meta[0] === 0 && geo.meta[1] === 1920, JSON.stringify(geo)); + await page.setViewport({ width: 390, height: 844 }); await sleep(300); + const narrow = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const b = s.querySelector('.mp-left').getBoundingClientRect(); return [Math.round(b.left), Math.round(s.querySelector('.mp-counts').getBoundingClientRect().right)]; }); + check('a phone keeps the full width', narrow[0] <= 16 && narrow[1] >= 370, JSON.stringify(narrow)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ the pager keeps its post through a resize' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/pager-sharpen-test.js b/tools/pager-sharpen-test.js new file mode 100644 index 0000000..ea5c878 --- /dev/null +++ b/tools/pager-sharpen-test.js @@ -0,0 +1,78 @@ +// A picture in the media pager flies in as the feed's column copy (the one +// the feed already decoded) and is sharpened to the original once its slide +// comes to rest; only the slide at rest carries the zoom layer. (Originals +// decoded and uploaded as they passed made a flight stutter — Adam, +// 2026-10-16.) Run: bun tools/pager-sharpen-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const PICS = { big1: png('2400x1800'), big2: png('1800x2400'), big3: png('2000x2000') }; +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const html = await buildHtml({ minify: true, pwa: false }); +const hits = []; +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + const m = /^\/(big\d)\.png$/.exec(path); + if (m) { hits.push(m[1]); return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png', 'access-control-allow-origin': '*' } }); } + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const seeded = [post('first big ' + U('big1'), 100), post('second big ' + U('big2'), 200), post('third big ' + U('big3'), 300), + ...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 500 + i * 60))]; +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const slides = () => page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => { const i = s.querySelector('img.mp-media'); const r = s.getBoundingClientRect(); return { k: s.getAttribute('data-k').split('/').pop(), src: (i.getAttribute('src') || '').replace(/^blob:.*/, 'blob:'), cur: s.classList.contains('mp-cur'), willChange: getComputedStyle(i).willChange, on: Math.abs(r.top) < 4 }; })); +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the feed paints its copies of the pictures', await waitFor(() => [...document.querySelectorAll('.notes-feed img.note-img')].filter((i) => i.getAttribute('src').startsWith('blob:')).length >= 2, null, 25000)); + const at = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('first big')); r.scrollIntoView({ block: 'center' }); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }); + await sleep(300); + await page.mouse.click(at.x, at.y); + check('the picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager .mp-slide img.mp-media'), null, 4000)); + let s = await slides(); + check('every slide carries the copy, not the original', s.length >= 3 && s.every((x) => x.src === 'blob:' || x.src === ''), JSON.stringify(s.map((x) => x.src))); + check('the slide on screen is the current one, alone with the zoom layer', s.filter((x) => x.cur).length === 1 && s.find((x) => x.cur).on && s.every((x) => (x.willChange === 'transform') === x.cur), JSON.stringify(s.map((x) => [x.cur, x.willChange]))); + check('…sharpened to the original once at rest', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur img.mp-media'); return c && c.getAttribute('src').endsWith('/big1.png'); }, null, 5000), JSON.stringify((await slides()).map((x) => x.src))); + s = await slides(); + check('the others stay on their copies', s.filter((x) => !x.cur).every((x) => x.src === 'blob:' || x.src === ''), JSON.stringify(s.map((x) => x.src))); + await page.keyboard.press('ArrowDown'); + check('down: the next slide becomes current', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur'); return c && c.getAttribute('data-k').endsWith('/big2.png'); }, null, 4000)); + check('…and is sharpened in its turn', await waitFor(() => { const c = document.querySelector('.mp-slide.mp-cur img.mp-media'); return c && c.getAttribute('src').endsWith('/big2.png'); }, null, 5000), JSON.stringify((await slides()).map((x) => x.src))); + s = await slides(); + check('only one slide has the zoom layer', s.filter((x) => x.willChange === 'transform').length === 1 && s.find((x) => x.willChange === 'transform').k === 'big2.png'); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ pager slides fly as copies and sharpen at rest' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/pager-swipe-fly-test.js b/tools/pager-swipe-fly-test.js new file mode 100644 index 0000000..d809793 --- /dev/null +++ b/tools/pager-swipe-fly-test.js @@ -0,0 +1,124 @@ +// On a phone, a fast flick up or down in the media pager flies past several +// posts and lands snapped on one, the way a spin of the wheel does; a +// gentler swipe is one post, as before; a fling at the end lands on the +// last post rather than wrapping past it; a finger put down mid-flight +// catches it. Run: bun tools/pager-swipe-fly-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const N = 9, PIC = png('360x360'); +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + if (/^\/p\d+\.png$/.test(path)) return new Response(PIC, { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/p' + n + '.png'; +const seeded = Array.from({ length: N }, (_, i) => finalizeEvent({ kind: 1, created_at: now - 100 - i * 100, tags: [], content: 'picture post ' + (i + 1) + ' ' + U(i + 1) }, SK)); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +// the post the pager rests square on (its number), or 'between' +const at = () => page.evaluate(() => { const s = [...document.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 1 && Math.abs(r.left) < 1; }); return s ? Number(/p(\d+)\.png/.exec(s.getAttribute('data-k'))[1]) : 'between'; }); +const settled = async () => { let last = null; for (let i = 0; i < 40; i++) { const t = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); if (t === last) { await sleep(100); if (await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop) === t) return; } last = t; await sleep(50); } }; +// a swipe up the screen: px of travel over about `ms` milliseconds. A +// quick one is dispatched inside the page, as fast as the handlers take +// the moves (about 30–50 ms each here, headless): the browser's own touch +// simulation takes a round trip per move on top, so a flick through it is +// never faster than a stroll. +const swipe = async (px, ms, steps = 6) => { + if (ms >= 100) { + await page.touchscreen.touchStart(195, 700); + for (let i = 1; i <= steps; i++) { await page.touchscreen.touchMove(195, 700 - px * i / steps); await sleep(ms / steps); } + await page.touchscreen.touchEnd(); + return; + } + await page.evaluate(([px, ms, steps]) => { + const el = document.querySelector('.mp-scroll'), target = document.elementFromPoint(195, 700) || el; + const touch = (y) => new Touch({ identifier: 1, target, clientX: 195, clientY: y, pageX: 195, pageY: y }); + const fire = (type, y, up) => target.dispatchEvent(new TouchEvent(type, { bubbles: true, cancelable: true, touches: up ? [] : [touch(y)], changedTouches: [touch(y)], targetTouches: up ? [] : [touch(y)] })); + // on a schedule from the start, not a pause after each move: a move's + // handling (the slides following the finger) takes its own time + const t0 = performance.now(); + fire('touchstart', 700); + for (let i = 1; i <= steps; i++) { while (performance.now() < t0 + ms * i / steps); fire('touchmove', 700 - px * i / steps); } + fire('touchend', 700 - px, true); + }, [px, ms, steps]); +}; +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true, isMobile: true }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 3, null, 20000)); + const pic = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes('picture post 1 ')); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }); + await page.touchscreen.tap(pic.x, pic.y); + check('the first picture opens in the pager', await waitFor(() => !!document.querySelector('.media-pager'), null, 4000) && (await (async () => { await settled(); return at(); })()) === 1, String(await at())); + check('…with all the posts loaded', await waitFor((n) => document.querySelectorAll('.mp-group').length >= n, N, 8000)); + + console.log('\n[a gentle swipe: one post]'); + await swipe(320, 130); await settled(); + check('a 320px swipe over 130ms: the next post', (await at()) === 2, String(await at())); + + console.log('\n[a fast flick: several posts, landing snapped]'); + const trace = async (sw) => { await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); window.__tr = []; const t0 = performance.now(); const rec = () => { window.__tr.push(sc.scrollTop); if (performance.now() - t0 < 1500) requestAnimationFrame(rec); }; requestAnimationFrame(rec); }); await sw(); await sleep(1600); return page.evaluate(() => window.__tr); }; + const tr = await trace(() => swipe(600, 80, 3)); await settled(); // three big moves: 3–6 px/ms as their handling takes, a long coast's worth (past the nine loaded, maybe) + const landed = await at(); + check('a 600px flick in a few frames flies several posts on', landed !== 'between' && landed >= 5, String(landed)); + check('…landing square on a post', landed !== 'between'); + check('…in a glide, never back', tr.every((x, i) => !i || x >= tr[i - 1] - 0.5), tr.slice(0, 12).map((x) => Math.round(x)).join(' ')); + const seen = await page.evaluate(() => document.querySelector('.mp-position').textContent); + check('the counter says where it landed', seen === landed + ' / ' + N, seen); + + console.log('\n[a flick near the end: the last post, not past it]'); + while ((await at()) !== 7) { await page.keyboard.press((await at()) < 7 ? 'ArrowDown' : 'ArrowUp'); await settled(); } + const before = await at(); + await swipe(600, 80, 3); await settled(); + check('from post ' + before + ' (a flight\'s worth would be past the end), a flick lands on the last post', before >= 7 && (await at()) === N, String(await at())); + await swipe(600, 80, 3); await settled(); + check('…and one more flick at the end wraps to the first', (await at()) === 1, String(await at())); + + console.log('\n[a finger down mid-flight catches it]'); + await swipe(600, 80, 3); + await sleep(120); + const mid = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); + await page.touchscreen.touchStart(195, 500); + await page.touchscreen.touchMove(195, 480); await sleep(30); + await page.touchscreen.touchMove(195, 470); await sleep(200); + const held = await page.evaluate(() => document.querySelector('.mp-scroll').scrollTop); + await page.touchscreen.touchEnd(); await settled(); + check('the flight stops under the finger', mid > 0 && Math.abs(held - mid) < 844, mid + ' → ' + held); + check('…and lands square on a post when it lifts', (await at()) !== 'between', String(await at())); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a fast flick flies through the pager' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/pager-wheel-test.js b/tools/pager-wheel-test.js new file mode 100644 index 0000000..45a5115 --- /dev/null +++ b/tools/pager-wheel-test.js @@ -0,0 +1,193 @@ +// The media pager with a mouse (a laptop): a wheel notch is a step — down +// the next post, up the one before, sideways (or shift+wheel) along a post's +// pictures — a trackpad's run of small deltas one step per run, a spin of +// the wheel not five; ctrl+wheel (a trackpad's pinch) zooms the picture, +// the wheel pans it while zoomed and pages again at 1×; and arrows at the +// edges for the pointer, the sideways pair only where the post has more. +// Run: bun tools/pager-wheel-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), e: png('240x180') }; + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, SK); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + const m = /^\/(a1|a2|b|d|e)\d*\.png$/.exec(path); + if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const seeded = [ + post('two pictures ' + U('a1') + ' ' + U('a2'), 100), + post('one picture ' + U('b'), 200), + post('only words here, nothing to look at', 300), + post('a last picture ' + U('d'), 400), + ...Array.from({ length: 12 }, (_, i) => post('more ' + i + ' ' + U('e' + i), 500 + i * 60)), // a dozen more, one picture each, for a flight +]; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox', '--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4'] /* a pointer that hovers: headless has none */ }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +// the slide the pager rests on, by its picture +const curPic = () => page.evaluate(() => { + const sc = document.querySelector('.mp-scroll'); if (!sc) return null; + const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); + return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between'; +}); +const resting = async (name) => rested(name).then(async (r) => { await sleep(260); return r; }); // a breath after: the wheel's gate between steps +const rested = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000); +const openPic = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, word); + await sleep(400); + const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2, y0: window.scrollY }; }, word); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager'), null, 4000); + return at.y0; +}; +const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); }; +const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); + +try { + await page.setViewport({ width: 1280, height: 900 }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000)); + const cdp = await page.target().createCDPSession(); + // a trackpad's run: small deltas, page-side — a CDP mouseWheel is always a 120-unit notch to Chrome + const pad = (dy, n) => page.evaluate(async ([dy, n]) => { const sc = document.querySelector('.mp-scroll'); for (let i = 0; i < n; i++) { const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const ev = new WheelEvent('wheel', { deltaY: dy, deltaX: 0, deltaMode: 0, bubbles: true, cancelable: true, clientX: 640, clientY: 450 }); Object.defineProperty(ev, 'wheelDeltaY', { value: -Math.round(dy * 1.2) }); s.querySelector('img.mp-media').dispatchEvent(ev); await new Promise((r) => setTimeout(r, 16)); } }, [dy, n]); + const pos = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); return sc ? Math.round(sc.scrollTop) + '/' + sc.clientHeight + ' glide:' + !!sc._glide + ' snap:' + sc.style.scrollSnapType : 'none'; }); + const wheel = (dy, { dx = 0, mods = 0 } = {}) => cdp.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 640, y: 450, deltaX: dx, deltaY: dy, modifiers: mods }); + const zoomOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /scale\(([\d.]+)\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? Number(m[1]) : 1; }); + const shiftOf = () => page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); const m = /translate\(([-\d.]+)px, ([-\d.]+)px\)/.exec(s.querySelector('img.mp-media').style.transform || ''); return m ? [Number(m[1]), Number(m[2])] : [0, 0]; }); + + console.log('\n[a wheel notch is a step]'); + await openPic('two pictures'); + check('the picture opens in the pager', await resting('a1.png'), await curPic()); + check('opening fills the media buffer without scrolling to the end', await waitFor(() => document.querySelectorAll('.mp-group').length === 15, null, 8000)); + check('background loading keeps the first picture on screen', await curPic() === 'a1.png'); + await page.mouse.move(640, 450); + await wheel(100); + check('a notch down: the next post\'s picture', await resting('b.png'), await curPic()); + await wheel(100); + check('another: the next, past a post with none', await resting('d.png'), await curPic()); + await wheel(-100); + check('a notch up: back one post', await resting('b.png'), await curPic()); + await wheel(-100); + check('up again: the first post', await resting('a1.png'), await curPic()); + await wheel(0, { dx: 100 }); + check('sideways: the post\'s second picture', await resting('a2.png'), await curPic()); + await wheel(0, { dx: 100 }); await sleep(300); + check('sideways at the post\'s last: stays', (await curPic()) === 'a2.png', await curPic()); + await wheel(-100, { mods: 8 }); + check('shift+wheel up: back to its first picture', await resting('a1.png'), await curPic()); + await wheel(-100, { mods: 8 }); await sleep(300); + check('shift+wheel at the first: stays', (await curPic()) === 'a1.png', await curPic()); + await wheel(100, { mods: 8 }); await resting('a2.png'); + await sleep(300); + console.log('\n[a spin flies, and lands snapped]'); + // posts with pictures, in order: a (two), b, d, e0 … e11 — a spin of five + // notches is 1 + 1 + 2 + 2 + 2 = 8 posts on from a: e5 + for (let i = 0; i < 5; i++) { await wheel(100); await sleep(10); } + await sleep(250); + const mid = await pos(); + check('mid-flight: between posts, gliding', /glide:true/.test(mid) && (await curPic()) === 'between', mid); + check('a spin of five notches flies eight posts on and lands snapped', await resting('e5.png'), await curPic() + ' ' + await pos()); + check('the landing is arrived at: the sideways arrows know the post', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('has-right'))); + for (let i = 0; i < 5; i++) { await wheel(-100); await sleep(10); } + check('a spin back flies back the same way', await resting('a2.png'), await curPic() + ' ' + await pos()); + // a notch against a flight catches it on the post it is passing + for (let i = 0; i < 5; i++) { await wheel(100); await sleep(10); } + await sleep(200); + await wheel(-100); + await sleep(900); + const caught = await curPic(); + check('a notch back mid-flight catches it, snapped on a post short of the target', caught !== 'between' && !['e5.png', 'a1.png', 'a2.png'].includes(caught), caught + ' ' + await pos()); + await sleep(300); + await wheel(-100); await resting(caught === 'b.png' ? 'a2.png' : caught === 'd.png' ? 'b.png' : caught === 'e0.png' ? 'd.png' : 'e' + (Number(caught[1]) - 1) + '.png'); + // back to b for what follows + while ((await curPic()) !== 'b.png') { await wheel((await curPic()) === 'a2.png' || (await curPic()) === 'a1.png' ? 100 : -100); await sleep(500); } + await sleep(300); + + console.log('\n[a trackpad\'s run is one step]'); + await sleep(300); + await pad(9, 14); + check('a run of small deltas steps once', await resting('d.png'), await curPic() + ' ' + await pos()); + await sleep(400); + check('…and only once', (await curPic()) === 'd.png', await curPic()); + await sleep(300); + await pad(-9, 14); + check('a run back steps back', await resting('b.png'), await curPic()); + + console.log('\n[ctrl+wheel zooms, the wheel pans, 1× pages]'); + await sleep(400); + await wheel(-100, { mods: 2 }); await sleep(100); + const z1 = await zoomOf(); + check('ctrl + a notch up zooms the picture in', z1 > 1.2, 'scale ' + z1); + await wheel(-100, { mods: 2 }); await sleep(100); + check('…and more', (await zoomOf()) > z1, 'scale ' + await zoomOf()); + const before = await shiftOf(); + await wheel(100); await sleep(300); + check('a plain notch while zoomed pans, it does not page', (await curPic()) === 'b.png' && JSON.stringify(await shiftOf()) !== JSON.stringify(before), await curPic() + ' ' + JSON.stringify(before) + ' → ' + JSON.stringify(await shiftOf())); + for (let i = 0; i < 6; i++) { await wheel(100, { mods: 2 }); await sleep(30); } + await sleep(100); + check('ctrl + notches down zoom back out to 1×', (await zoomOf()) === 1, 'scale ' + await zoomOf()); + await sleep(300); + await wheel(100); + check('at 1× the wheel pages again', await resting('d.png'), await curPic()); + + console.log('\n[arrows for the pointer]'); + const shown = (sel) => page.evaluate((s) => { const b = document.querySelector(s); return !!b && getComputedStyle(b).display !== 'none' && b.getBoundingClientRect().width > 0; }, sel); + check('up and down arrows are on the pager', await shown('.mp-arrow.mp-up') && await shown('.mp-arrow.mp-down')); + check('a post with one picture shows no sideways arrows', !(await shown('.mp-arrow.mp-ar-left')) && !(await shown('.mp-arrow.mp-ar-right'))); + await page.click('.mp-arrow.mp-up'); + check('the up arrow: back one post', await resting('b.png'), await curPic()); + await page.click('.mp-arrow.mp-up'); + check('up again: the first post, on the picture it was left at', await resting('a2.png'), await curPic()); + await sleep(200); + check('on its last picture: a left arrow, no right', await shown('.mp-arrow.mp-ar-left') && !(await shown('.mp-arrow.mp-ar-right'))); + await page.click('.mp-arrow.mp-ar-left'); + check('the left arrow: its first picture', await resting('a1.png'), await curPic()); + await sleep(200); + check('on its first: a right arrow, no left', await shown('.mp-arrow.mp-ar-right') && !(await shown('.mp-arrow.mp-ar-left'))); + await page.click('.mp-arrow.mp-ar-right'); + check('the right arrow: its second picture', await resting('a2.png'), await curPic()); + check('an arrow\'s click is not a tap on the picture (no details, still open)', await page.evaluate(() => { const p = document.querySelector('.media-pager'); return !!p && !p.classList.contains('show'); })); + await page.click('.mp-arrow.mp-down'); + check('the down arrow: the next post', await resting('b.png'), await curPic()); + await page.keyboard.press('Escape'); + check('Escape still closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ pager: wheel steps, ctrl+wheel zooms, arrows for the pointer' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/profile-pager-test.js b/tools/profile-pager-test.js new file mode 100644 index 0000000..29341e7 --- /dev/null +++ b/tools/profile-pager-test.js @@ -0,0 +1,135 @@ +// A picture tapped on someone's profile page opens in the media pager over +// THEIR pictures — the posts of their timeline, in its order, nobody +// else's — and closing it after moving on lands the profile on that post. +// (It opened alone, in the lightbox, before: no pager on a profile.) +// Run: bun tools/profile-pager-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), x: png('200x200') }; + +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), SK2 = generateSecretKey(), OTHER = getPublicKey(SK2); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago, sk = SK) => finalizeEvent({ kind: 1, created_at: now - ago, tags: [], content }, sk); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + const m = /^\/(a1|a2|b|d|x)\.png$/.exec(path); + if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const seeded = [ + post('bob picture ' + U('x'), 50, SK2), // someone else's, newest in the feed: not in Alice's pager + post('two pictures ' + U('a1') + ' ' + U('a2'), 100), + post('one picture ' + U('b'), 200), + post('only words here, nothing to look at', 300), + post('a last picture ' + U('d'), 400), + ...Array.from({ length: 12 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 500 + i * 60)), +]; + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +// the slide the pager rests on, by its picture +const curPic = () => page.evaluate(() => { + const sc = document.querySelector('.mp-scroll'); if (!sc) return null; + const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); + return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between'; +}); +const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000); +const openPic = async (word) => { + await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, word); + await sleep(400); + const at = await page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); const i = r.querySelector('img.note-img').getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2, y0: window.scrollY }; }, word); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager'), null, 4000); + return at.y0; +}; +const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); }; +const rowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.notes-feed [data-key]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); + +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true }); + await page.goto(server.url.href, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + await page.evaluate(([k, pk, ns, other]) => { + localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk], ['p', other]], c: '', at: Math.floor(Date.now() / 1000) })); + localStorage.setItem(k + ':feedNotes', JSON.stringify(ns)); + localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Picture Poster', about: 'alice bio here', t: Date.now() }, [other]: { name: 'Bob Poster', t: Date.now() } })); + localStorage.setItem('btc-wallet-feed:' + k, 'following'); + }, [base, AUTHOR, seeded, OTHER]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click()); + check('the pictures are in the feed', await waitFor(() => document.querySelectorAll('.notes-feed img.note-img').length >= 4, null, 20000)); + + console.log('\n[a picture on a profile page]'); + // Alice's profile, from her face on a feed row + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('one picture')); r.scrollIntoView({ block: 'center' }); }); + await sleep(300); + const face = await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed .note-avatar')].find((n) => n.closest('[data-key]')?.innerText.includes('one picture')).getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); + await page.mouse.click(face.x, face.y); + check('her profile opens, with her posts', await waitText('alice bio here', 8000) && await waitFor(() => [...document.querySelectorAll('.notes-feed [data-key]')].some((r) => r.innerText.includes('two pictures')), null, 8000)); + check('…and not Bob\'s', !(await page.evaluate(() => document.body.innerText.includes('bob picture')))); + await openPic('two pictures'); + check('her picture opens in the pager, on itself', await resting('a1.png'), await curPic()); + const keys = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('/').pop())); + check('the pager holds her pictures only, in her timeline\'s order', JSON.stringify(keys) === JSON.stringify(['a1.png', 'a2.png', 'b.png', 'd.png']), JSON.stringify(keys)); + check('counter starts at the first post', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3'); + await page.keyboard.press('ArrowRight'); + check('right: the post\'s second picture', await resting('a2.png'), await curPic()); + check('sideways keeps the same post number', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3'); + await page.keyboard.press('ArrowDown'); + check('down: her next post with a picture', await resting('b.png'), await curPic()); + check('down advances by one post', await page.$eval('.mp-position', (e) => e.textContent) === '2 / 3'); + await page.keyboard.press('ArrowDown'); + check('down again: her last', await resting('d.png'), await curPic()); + await page.keyboard.press('ArrowDown'); await sleep(600); + check('past her last wraps to her first post', await resting('a2.png'), await curPic()); + check('counter counts the first post once', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 3'); + check('no loading slide', await page.$('.mp-tail') === null); + await page.keyboard.press('ArrowUp'); await resting('d.png'); + check('up from first wraps to last', await curPic() === 'd.png'); + check('counter at the last post', await page.$eval('.mp-position', (e) => e.textContent) === '3 / 3'); + await page.touchscreen.touchStart(195, 650); + for (let i = 1; i <= 8; i++) { await page.touchscreen.touchMove(195, 650 - 40 * i); await sleep(16); } + await page.touchscreen.touchEnd(); + check('touch swipe wraps to first post', await resting('a2.png'), await curPic()); + await page.evaluate(() => document.querySelector('.media-pager').dispatchEvent(new WheelEvent('wheel', { deltaY: -3, deltaMode: 1, bubbles: true, cancelable: true }))); + check('wheel wraps back to last post', await resting('d.png'), await curPic()); + await page.keyboard.press('ArrowUp'); + check('up: her post before', await resting('b.png'), await curPic()); + await page.keyboard.press('Escape'); + check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager') && !!document.querySelector('.notes-feed'), null, 3000)); + await sleep(900); + check('…still on her profile', await page.evaluate(() => document.body.innerText.includes('alice bio here'))); + check('…landed on that post', (await rowTop('one picture')) !== null && (await rowTop('one picture')) < 120 && (await rowTop('one picture')) > -10, 'row top ' + (await rowTop('one picture'))); + await openPic('one picture'); + check('opened again on the picture it closed on', await resting('b.png'), await curPic()); + await tapPager(); await sleep(400); + check('a tap on the picture that opened it shows its details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); + await tapPager(); await sleep(600); + check('…and the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a profile\'s pictures page through the pager' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/thread-pager-test.js b/tools/thread-pager-test.js new file mode 100644 index 0000000..0f99742 --- /dev/null +++ b/tools/thread-pager-test.js @@ -0,0 +1,134 @@ +// A picture tapped on a thread page opens in the media pager over the +// THREAD's pictures — the root's and its replies', in the page's order, +// nobody else's — with the caption and controls a tap away, and closing it +// leaves the thread on screen. (It opened alone, in the lightbox, before: +// a tap on it closed it, with no caption or controls — Adam, 2026-10-16.) +// Run: bun tools/thread-pager-test.js +import puppeteer from './puppeteer.js'; +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const png = (size) => Bun.spawnSync(['ffmpeg', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc=size=' + size, '-frames:v', '1', '-f', 'image2', '-c:v', 'png', 'pipe:1']).stdout; +const PICS = { a1: png('400x300'), a2: png('300x400'), b: png('320x320'), d: png('360x240'), x: png('200x200') }; +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 0, fetch: (req) => { + const path = new URL(req.url).pathname; + if (path === '/api/feed') return new Response(snapshot, { headers: { 'content-type': 'application/json' } }); + if (path.startsWith('/punks')) return new Response(Bun.file('dist' + path)); + const m = /^\/(a1|a2|b|d|x)\.png$/.exec(path); + if (m) return new Response(PICS[m[1]], { headers: { 'content-type': 'image/png' } }); + return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } }); +} }); +const U = (n) => server.url.origin + '/' + n + '.png'; +const SK = generateSecretKey(), AUTHOR = getPublicKey(SK), SK2 = generateSecretKey(), OTHER = getPublicKey(SK2); +const now = Math.floor(Date.now() / 1000); +const post = (content, ago, sk = SK, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, sk); +const root = post('two pictures in the root ' + U('a1') + ' ' + U('a2'), 600); +const replyWords = post('a reply with only words', 500, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]); +const replyPic = post('a reply with a picture ' + U('b'), 400, SK2, [['e', root.id, '', 'root'], ['p', AUTHOR]]); +const feedNotes = [ + post('someone else, newer, with a picture ' + U('x'), 100, SK2), + post('another post with a picture ' + U('d'), 200), + root, + ...Array.from({ length: 10 }, (_, i) => post('filler ' + i + ' ' + 'lorem ipsum dolor sit amet. '.repeat(5), 700 + i * 60)), +]; +const replies = [replyWords, replyPic]; +const profiles = [finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Picture Poster' }) }, SK), + finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Reply Writer' }) }, SK2)]; +const snapshot = JSON.stringify({ version: 1, generatedAt: Date.now(), notes: feedNotes, profiles, popUntil: now - 3600 }); + +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const waitFor = async (fn, arg, ms = 10000) => { for (let i = 0; i < ms / 100; i++) { if (await page.evaluate(fn, arg)) return true; await sleep(100); } return false; }; +const curPic = () => page.evaluate(() => { + const sc = document.querySelector('.mp-scroll'); if (!sc) return null; + const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); + return s ? s.getAttribute('data-k').split('|')[1].split('/').pop() : 'between'; +}); +const resting = async (name) => waitFor((n) => { const sc = document.querySelector('.mp-scroll'); if (!sc) return false; const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return !!s && s.getAttribute('data-k').endsWith('/' + n); }, name, 4000); +const tapPager = async () => { const at = await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }) || sc.firstChild; const r = s.querySelector('img.mp-media').getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); await page.mouse.click(at.x, at.y); }; +const openPicIn = async (scope, word, nth = 0) => { + await page.evaluate(([sc, w]) => { const r = [...document.querySelectorAll(sc)].find((n) => n.textContent.includes(w)); r.scrollIntoView({ block: 'center' }); }, [scope, word]); + await sleep(400); + const at = await page.evaluate(([sc, w, n]) => { const r = [...document.querySelectorAll(sc)].find((x) => x.textContent.includes(w)); const i = r.querySelectorAll('img.note-img')[n].getBoundingClientRect(); return { x: i.x + i.width / 2, y: i.y + i.height / 2 }; }, [scope, word, nth]); + await page.mouse.click(at.x, at.y); + await waitFor(() => !!document.querySelector('.media-pager, .lightbox'), null, 4000); +}; +const threadRowTop = (word) => page.evaluate((w) => { const r = [...document.querySelectorAll('.thread-page [data-zap-post]')].find((n) => n.textContent.includes(w)); return r ? Math.round(r.getBoundingClientRect().top) : null; }, word); + +try { + await page.setViewport({ width: 390, height: 844, hasTouch: true }); + await page.setRequestInterception(true); + page.on('request', (r) => r.url().startsWith(server.url.origin) ? r.continue() : r.abort()); + await page.evaluateOnNewDocument(([notes, replies, profiles]) => { + class FakeSocket extends EventTarget { + constructor(url) { super(); this.url = url; this.readyState = 0; this.subs = new Set(); + setTimeout(() => { this.readyState = 1; this.emit('open', new Event('open')); }, 0); } + emit(type, ev) { this.dispatchEvent(ev); this['on' + type]?.(ev); } + message(d) { this.emit('message', new MessageEvent('message', { data: JSON.stringify(d) })); } + send(raw) { + let m; try { m = JSON.parse(raw); } catch { return; } + if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; } + if (m[0] !== 'REQ') return; + const [, id, f] = m; this.subs.add(id); + let evs = []; + const all = [...notes, ...replies]; + if (f['#e']) evs = replies.filter((e) => e.tags.some((t) => t[0] === 'e' && f['#e'].includes(t[1]))); + else if (f.ids) evs = all.filter((e) => f.ids.includes(e.id)); + else if (f.kinds?.includes(1) && !f.since) evs = notes.filter((e) => (!f.until || e.created_at <= f.until) && (!f.authors || f.authors.includes(e.pubkey))).slice(0, f.limit || 100); + if (f.kinds?.includes(0)) evs = profiles.filter((p) => !f.authors || f.authors.includes(p.pubkey)); + setTimeout(() => { if (!this.subs.has(id)) return; for (const e of evs) this.message(['EVENT', id, e]); this.message(['EOSE', id]); }, 30); + } + close() { this.readyState = 3; this.emit('close', new Event('close')); } + } + Object.assign(FakeSocket, { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }); + window.WebSocket = FakeSocket; + }, [feedNotes, replies, profiles]); + await page.goto(server.url.origin + '/feed', { waitUntil: 'domcontentloaded' }); + check('the feed shows the posts', await waitFor(() => document.querySelectorAll('.notes-feed > [data-zap-post]').length >= 10, null, 20000)); + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); r.scrollIntoView({ block: 'center' }); }); + await sleep(300); + await page.evaluate(() => { const r = [...document.querySelectorAll('.notes-feed > [data-key]')].find((n) => n.textContent.includes('two pictures in the root')); (r.querySelector('.note-text') || r).click(); }); + check('the thread opens', await waitFor(() => !!document.querySelector('.thread-page'), null, 5000)); + check('…with its replies', await waitFor(() => document.body.innerText.includes('a reply with a picture'), null, 8000)); + await sleep(500); + + console.log('\n[a picture on a thread page]'); + await openPicIn('.thread-page [data-zap-post]', 'two pictures in the root'); + check('it opens in the media pager, not the lightbox', await page.evaluate(() => !!document.querySelector('.media-pager') && !document.querySelector('.lightbox'))); + check('…on itself', await resting('a1.png'), await curPic()); + const keys = await page.evaluate(() => [...document.querySelectorAll('.mp-slide')].map((s) => s.getAttribute('data-k').split('/').pop())); + check('the pager holds the thread\'s pictures only, root first then the replies', JSON.stringify(keys) === JSON.stringify(['a1.png', 'a2.png', 'b.png']), JSON.stringify(keys)); + check('counter: first of two posts', await page.$eval('.mp-position', (e) => e.textContent) === '1 / 2'); + check('no details until a tap', await page.evaluate(() => !document.querySelector('.media-pager').classList.contains('show'))); + await tapPager(); await sleep(400); + check('a tap shows the caption and controls', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); + check('…with the author and the words', await page.evaluate(() => { const sc = document.querySelector('.mp-scroll'); const s = [...sc.querySelectorAll('.mp-slide')].find((x) => { const r = x.getBoundingClientRect(); return Math.abs(r.top) < 4 && Math.abs(r.left) < 4; }); return /Picture Poster/.test(s.textContent) && /two pictures in the root/.test(s.textContent); })); + await page.keyboard.press('ArrowRight'); + check('right: the root\'s second picture', await resting('a2.png'), await curPic()); + await page.keyboard.press('ArrowDown'); + check('down: the reply\'s picture', await resting('b.png'), await curPic()); + check('counter: second post', await page.$eval('.mp-position', (e) => e.textContent) === '2 / 2'); + check('no loading slide (nothing more to fetch)', await page.$('.mp-tail') === null); + await page.keyboard.press('Escape'); + check('Escape closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + await sleep(600); + check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-page') && document.body.innerText.includes('a reply with only words'))); + check('…with the reply\'s post on screen', (await threadRowTop('a reply with a picture')) !== null && (await threadRowTop('a reply with a picture')) >= -10 && (await threadRowTop('a reply with a picture')) < 844, 'row top ' + (await threadRowTop('a reply with a picture'))); + + console.log('\n[a reply\'s picture, and a tap then another]'); + await openPicIn('.thread-page [data-zap-post]', 'a reply with a picture'); + check('opens on the reply\'s picture', await resting('b.png'), await curPic()); + await tapPager(); await sleep(400); + check('a tap shows the details', await page.evaluate(() => document.querySelector('.media-pager')?.classList.contains('show'))); + await tapPager(); await sleep(600); + check('the next closes it', await waitFor(() => !document.querySelector('.media-pager'), null, 3000)); + check('…still on the thread', await page.evaluate(() => !!document.querySelector('.thread-page'))); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ a thread\'s pictures page through the pager' : '\n❌ failed'); +process.exit(ok ? 0 : 1); diff --git a/tools/wn-only-device-test.js b/tools/wn-only-device-test.js new file mode 100644 index 0000000..a91262b --- /dev/null +++ b/tools/wn-only-device-test.js @@ -0,0 +1,129 @@ +// Settings → Nostr → Group invitations: a Marmot inviter (White Noise) +// picks ONE of your published KeyPackages, so the group lands on one device +// and nothing says which. The card lists the other keys of yours on the +// relays and "Invite this device only" retires them (a kind 5 naming each: +// NIP-09 `a` for the addressable 30443s, `e` for an old 443) and publishes +// a fresh one of ours. A device whose key was retired by another sees the +// deletion and stops putting a key back; the card says so there. +// Run: bun tools/wn-only-device-test.js +import puppeteer from './puppeteer.js'; +import { cacheKeyFor } from '../src/wallet.js'; +import { getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let ok = true; +const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; }; +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5341, fetch: (req) => { const p = new URL(req.url).pathname; if (p.startsWith('/punks') || p === '/verify-worker.js' || /\.js$/.test(p)) { const f = Bun.file('dist' + p); return f.size ? new Response(f) : new Response('', { status: 404 }); } return new Response(html, { headers: { 'content-type': 'text/html' } }); } }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +// a stand-in for every relay: the account's key packages and deletions are +// whatever the test says, an EVENT is recorded and OK'd, everything else is empty +await page.evaluateOnNewDocument(() => { + window.__published = []; window.__kps = []; window.__dels = []; window.__reqs = []; + const Real = window.WebSocket; + window.WebSocket = function (...a) { + const ws = new Real(...a); + const send = ws.send.bind(ws); + const answer = (id, evs) => setTimeout(() => { + for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) })); + ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) })); + }, 0); + ws.send = (data) => { + try { + const m = JSON.parse(data); + if (m[0] === 'REQ') { + const out = []; + for (const f of m.slice(2)) { + window.__reqs.push(f); + const kinds = f.kinds || []; + if ((kinds.includes(30443) || kinds.includes(443)) && f.authors) out.push(...window.__kps.filter((e) => kinds.includes(e.kind) && f.authors.includes(e.pubkey)), ...window.__published.filter((e) => e.kind === 30443 && f.authors.includes(e.pubkey))); + if (kinds.includes(5) && f.authors) out.push(...window.__dels.filter((e) => f.authors.includes(e.pubkey) && (!f['#a'] || e.tags.some((t) => t[0] === 'a' && f['#a'].includes(t[1]))))); + } + answer(m[1], out); + return; + } + if (m[0] === 'EVENT' && m[1]) { + window.__published.push(m[1]); + // the relay honours the deletion: the keys it names are gone + if (m[1].kind === 5) { const ids = new Set(m[1].tags.filter((t) => t[0] === 'e').map((t) => t[1])); const as = new Set(m[1].tags.filter((t) => t[0] === 'a').map((t) => t[1])); window.__kps = window.__kps.filter((e) => !ids.has(e.id) && !as.has('30443:' + e.pubkey + ':' + (e.tags.find((t) => t[0] === 'd') || [])[1])); } + setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0); + return; + } + } catch {} + return send(data); + }; + return ws; + }; + window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real); +}); +const errs = []; +page.on('pageerror', (e) => errs.push(String(e))); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const text = () => page.evaluate(() => document.body.innerText); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn)) return true; await sleep(250); } return false; }; +const openNostrSettings = async () => { + await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings'); b.click(); }); await sleep(600); + await page.evaluate(() => { const b = [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Nostr')); if (b) b.click(); }); await sleep(800); +}; +const card = () => page.evaluate(() => { const c = document.querySelector('.wn-devices-card'); return c ? { state: c.querySelector('.wn-device-state')?.textContent || '', others: [...c.querySelectorAll('.wn-device')].map((e) => e.textContent), none: !!c.querySelector('.wn-no-others'), btn: !!c.querySelector('.wn-only-here') } : null; }); + +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.origin, { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); await sleep(400); + await click('create a new wallet'); await sleep(600); + await page.waitForSelector('.words .w .t'); + const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' '); + await click('skip verification'); + await waitText('receive', 20000); + const base = cacheKeyFor(mn + '\n'); + const SK = privateKeyFromSeedWords(mn), ME = getPublicKey(SK); + const now = Math.floor(Date.now() / 1000); + const slotA = 'a'.repeat(64), slotB = 'b'.repeat(64); + const kp = (slot, client, ago) => finalizeEvent({ kind: 30443, created_at: now - ago, tags: [['d', slot], ['client', client], ['mls_protocol_version', '1.0']], content: 'AAAA' }, SK); + const OTHERS = [kp(slotA, 'White Noise Android', 7 * 86400), kp(slotB, 'coinos', 2 * 86400), finalizeEvent({ kind: 443, created_at: now - 20 * 86400, tags: [['client', 'MDK/0.7.1']], content: 'AAAA' }, SK)]; + await page.evaluateOnNewDocument((kps) => { window.__kps = kps; }, OTHERS); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + + console.log('\n[the card]'); + await openNostrSettings(); + check('Settings → Nostr has a Group invitations card', await waitFor(() => !!document.querySelector('.wn-devices-card'), 8000)); + check('…that lists the three other keys, newest first, by app', await waitFor(() => document.querySelectorAll('.wn-devices-card .wn-device').length === 3, 10000) && (await card()).others[0].startsWith('coinos') && (await card()).others[1].startsWith('White Noise Android') && (await card()).others[2].startsWith('MDK/0.7.1'), JSON.stringify(await card())); + check('…and says whether this device\'s key is up yet (a seed wallet publishes it at boot, racing the card)', /can be invited|no invitation key/i.test((await card()).state), (await card()).state); + + console.log('\n[Invite this device only]'); + await page.evaluate(() => document.querySelector('.wn-only-here').click()); + check('a kind 5 goes out', await waitFor(() => window.__published.some((e) => e.kind === 5), 15000)); + const del = await page.evaluate(() => window.__published.find((e) => e.kind === 5)); + check('…by the account, naming the two slots (a) and all three events (e)', del.pubkey === ME + && del.tags.some((t) => t[0] === 'a' && t[1] === '30443:' + ME + ':' + slotA) && del.tags.some((t) => t[0] === 'a' && t[1] === '30443:' + ME + ':' + slotB) + && OTHERS.every((o) => del.tags.some((t) => t[0] === 'e' && t[1] === o.id)), JSON.stringify(del.tags)); + check('…and a fresh key of this device', await waitFor(() => window.__published.some((e) => e.kind === 30443), 10000)); + const mine = await page.evaluate(() => window.__published.find((e) => e.kind === 30443)); + check('…which the deletion does not name', !del.tags.some((t) => t[0] === 'a' && t[1].endsWith(':' + (mine.tags.find((x) => x[0] === 'd') || [])[1]))); + check('the card now says this device can be invited, no others', await waitFor(() => /can be invited/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || '') && !!document.querySelector('.wn-devices-card .wn-no-others'), 10000), JSON.stringify(await card())); + + console.log('\n[retired by another device]'); + const mySlot = (mine.tags.find((x) => x[0] === 'd') || [])[1]; + const retire = finalizeEvent({ kind: 5, created_at: now + 60, tags: [['a', '30443:' + ME + ':' + mySlot], ['e', mine.id], ['k', '30443']], content: 'group invitations go to one device' }, SK); + await page.evaluateOnNewDocument((d) => { window.__dels = [d]; }, retire); + // the device asks the relays about this a few times a day: make it due now + await page.evaluate(async ([k, pk]) => { const db = await new Promise((res, rej) => { const r = indexedDB.open('coinos-marmot'); r.onsuccess = () => res(r.result); r.onerror = rej; }); const key = k + ':' + pk + '|dev'; const st = db.transaction('kv', 'readwrite').objectStore('kv'); const v = await new Promise((res) => { const g = st.get(key); g.onsuccess = () => res(g.result); }); const o = typeof v === 'string' ? JSON.parse(v) : v; o.retireCheckedAt = 0; await new Promise((res) => { const p = st.put(typeof v === 'string' ? JSON.stringify(o) : o, key); p.onsuccess = res; }); }, [base, ME]); + await page.reload({ waitUntil: 'domcontentloaded' }); + await waitText('receive', 20000); + await openNostrSettings(); + check('the card says this device no longer takes invitations', await waitFor(() => /no longer takes/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || ''), 12000), (await card())?.state); + await sleep(1500); + check('…and no key of this device was put back', !(await page.evaluate(() => window.__published.some((e) => e.kind === 30443)))); + await page.evaluate(() => document.querySelector('.wn-only-here').click()); + check('"Invite this device only" here takes the invitations back: a fresh key', await waitFor(() => window.__published.some((e) => e.kind === 30443), 12000)); + check('…and the card says it can be invited again', await waitFor(() => /can be invited/i.test(document.querySelector('.wn-devices-card .wn-device-state')?.textContent || ''), 10000), (await card())?.state); + check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | ')); +} catch (e) { console.error(e); ok = false; } finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ one device takes the group invitations' : '\n❌ failed'); +process.exit(ok ? 0 : 1); |
