diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 62 | ||||
| -rw-r--r-- | src/features/messages.js | 703 | ||||
| -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 |
7 files changed, 933 insertions, 212 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 8f5adcb..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(); } @@ -14743,10 +15074,33 @@ export function messagesFeature(ctx) { (ev) => { if (mergeNotifs([ev])) scheduleRepaint(); }); } function stopNotifWatch() { if (notifUnsub) { try { notifUnsub(); } catch {} notifUnsub = null; } } - // Shown as a notification: not from someone hidden, and not something your - // follows flagged — the thread folds that post, so the tap would land on a - // fold instead of the reply it promised (or the author, flagged enough). + // Counted as a notification: not from someone hidden, and not something + // your follows flagged — the page folds that row (notifFoldRow) rather + // than count it as news. const notifShown = (x) => !hiddenPk(x.actor) && !reportsOn({ id: x.id, pubkey: x.actor }); + // Who among your follows flagged this row's author or post, unless you + // asked to see it: the row is folded, not dropped — a reply from someone + // two follows reported as spam vanished from the page without a trace + // (Adam's phone, 2026-10-14), while the thread would have shown a fold. + const notifFlagged = (x) => (ui.revealed && ui.revealed.has(x.id)) ? null : reportsOn({ id: x.id, pubkey: x.actor }); + function notifFoldRow(x, by) { + profileOf(x.actor); + for (const pk of by.slice(0, 3)) profileOf(pk); + const who = by.length <= 3 ? t('foldReported1', { name: by.map((pk) => displayName(pk)).join(', ') }) : t('foldReportedN', { n: by.length }); + return h('div', { class: 'row alert-row note-folded', style: 'gap:10px;align-items:center;padding:10px 0;opacity:.75' }, + avatar(x.actor, 'chat-avatar note-avatar', false), + h('div', { class: 'col grow', style: 'min-width:0;gap:3px' }, + h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' }, + h('span', { style: 'font-weight:600;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' }, displayName(x.actor)), + h('span', { class: 'small', style: 'min-width:0' }, notifLabel(x)), + h('span', { class: 'small faint', style: 'white-space:nowrap;margin-left:auto' }, timeLabel(x.ts * 1000))), + h('div', { class: 'small muted' }, '\u26a0 ' + who)), + // seen here, the reply is seen in its thread too (the same id) + h('button', { + class: 'btn-sm', type: 'button', style: 'flex-shrink:0', + onClick: (e) => { e.stopPropagation(); (ui.revealed ||= new Set()).add(x.id); render(); }, + }, t('foldShow'))); + } const notifUnread = () => notifNow().items.filter((x) => x.ts > notifSeen() && notifShown(x)).length; function markNotifsSeen() { const newest = Math.max(0, ...notifNow().items.map((x) => x.ts)); @@ -14854,9 +15208,10 @@ export function messagesFeature(ctx) { } function notifView() { const c = notifNow(); + syncReports().catch(() => {}); // the folds below follow your follows' flags (throttled inside) markNotifsSeen(); // anything that lands while you look is seen too - const items = c.items.filter(notifShown); // a mute or report made later applies to what was kept - const rows = items.flatMap((x, i) => [i ? noteSep() : null, notifRow(x)]); + const items = c.items.filter((x) => !hiddenPk(x.actor)); // a mute made later applies to what was kept + const rows = items.flatMap((x, i) => { const by = notifFlagged(x); return [i ? noteSep() : null, by ? notifFoldRow(x, by) : notifRow(x)]; }); return h('div', { class: 'card col chat-page', style: 'gap:10px' }, h('div', { class: 'row gap6', style: 'align-items:center' }, backBtn(() => { ui.msgView = 'home'; stopNotifWatch(); render(); }), @@ -14903,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; } |
