aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js703
-rw-r--r--src/features/zaps.js138
2 files changed, 663 insertions, 178 deletions
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;