aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/messages.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/messages.js')
-rw-r--r--src/features/messages.js1071
1 files changed, 817 insertions, 254 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 16ae3a2..a40f7d5 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() {
@@ -456,7 +462,7 @@ export function messagesFeature(ctx) {
const feedsChanged = () => {
// a feed that takes in your lists is built from the other feeds: a
// changed list rebuilds it next time it's opened
- for (const f of st().feeds) if (expands(f)) { const c = feedStates.get(f.id); if (c && c !== feed) feedStates.delete(f.id); }
+ for (const f of st().feeds) if (expands(f)) for (const k of [f.id, f.id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c && c !== feed) feedStates.delete(k); }
clearTimeout(feedsPush);
feedsPush = setTimeout(() => { try { wallet.saveCache(); } catch {} }, 1000);
};
@@ -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 });
@@ -4004,6 +4012,48 @@ export function messagesFeature(ctx) {
// replies must not crowd its likes and zaps out of it.
const REPLY_KINDS = [1, 1111];
const replyIds = new Map(); // note id -> Map(reply id -> author pubkey)
+ // The replies themselves, as the count fetch brought them — a thread's
+ // first page, in hand before it is opened: every post on screen has its
+ // replies asked for (watchZaps), so opening it paints the conversation at
+ // once and the relays only add to it. Bounded: the targets most recently
+ // heard from, a few dozen replies each.
+ const replyEvents = new Map(); // note id -> Map(reply id -> event)
+ const REPLY_KEEP_TARGETS = 400, REPLY_KEEP_EACH = 60;
+ function keepReply(target, ev) {
+ let m = replyEvents.get(target);
+ if (m) replyEvents.delete(target); else m = new Map(); // re-set: most recent last
+ if (!m.has(ev.id)) { if (m.size >= REPLY_KEEP_EACH) return; m.set(ev.id, ev); }
+ replyEvents.set(target, m);
+ while (replyEvents.size > REPLY_KEEP_TARGETS) replyEvents.delete(replyEvents.keys().next().value);
+ }
+ // The replies in hand for a thread: those naming the root, and those
+ // naming only the reply they answer (one hop down), oldest first.
+ function repliesInHand(rootId) {
+ const out = new Map();
+ const take = (id) => { for (const [rid, ev] of replyEvents.get(id) || []) if (rid !== rootId && !out.has(rid)) out.set(rid, ev); };
+ take(rootId);
+ for (const rid of [...out.keys()]) take(rid);
+ return [...out.values()].sort((a, b) => a.created_at - b.created_at);
+ }
+ // …into a thread's cache entry: on opening it, and again when the count
+ // fetch answers for a thread fetched ahead before the answer came
+ function adoptRepliesInHand(c) {
+ const have = new Set(c.replies.map((e) => e.id));
+ let added = [];
+ for (const e of repliesInHand(c.rootId)) if (!have.has(e.id) && e.id !== c.rootId) { have.add(e.id); c.replies.push(e); noteForSpam(e); added.push(e); }
+ if (added.length) { c.replies.sort((a, b) => a.created_at - b.created_at); warmThread(added); }
+ return added.length;
+ }
+ // A thread's rows painted in their final shape: the faces, pictures,
+ // previews and quotes of what is in hand are fetched ahead (noteReady, as
+ // the feed does per row), so an opened thread doesn't shift as they land.
+ const threadWarmed = new WeakSet();
+ const THREAD_ROW_MS = 1500; // a reply arriving from a relay waits this long at most for its face
+ function warmThread(evs, deadline = Date.now() + READY_MS) {
+ const fresh = evs.filter((e) => e && !threadWarmed.has(e));
+ for (const e of fresh) threadWarmed.add(e);
+ return Promise.all(fresh.map((e) => noteReady(e, deadline).catch(() => {})));
+ }
const reacts = new Map(); // note id -> Map([emoji, url] -> { emoji, url, who: Set<pubkey> })
// Resolve from this reaction's own tag: packs can reuse the same shortcode
// for different pictures, and an unrelated event must not change its face.
@@ -4373,6 +4423,7 @@ export function messagesFeature(ctx) {
let m = replyIds.get(x[1]);
if (!m) replyIds.set(x[1], m = new Map());
if (!m.has(ev.id)) { m.set(ev.id, ev.pubkey); changed = true; }
+ keepReply(x[1], ev);
}
if (changed) scheduleRepaint();
}
@@ -4435,6 +4486,8 @@ export function messagesFeature(ctx) {
noteCountsReady.add(id);
if (c && (c.root?.id === id || c.replies.some((e) => e.id === id))) refreshThreadCount(id, false);
}
+ // the open thread takes the replies this answer brought
+ if (c && slice.includes(c.rootId) && adoptRepliesInHand(c)) renderThreadStable();
if (c?.root) persistThread(c);
saveZapTotals();
scheduleRepaint();
@@ -4700,6 +4753,41 @@ export function messagesFeature(ctx) {
const profTab = (pk) => (ui.profTab && ui.profTab.pk === pk ? ui.profTab.tab : 'posts');
// what goes under Replies: a note answering another (NIP-10), or a NIP-22 comment
const replyish = (e) => e.kind === 1111 || (e.kind === 1 && isReply(e));
+ // A conversations feed (Amethyst's "Conversations" beside its "New
+ // Threads"): the replies these people write, each under the post it
+ // answers, instead of their posts. Asked for as notes and NIP-22 comments;
+ // reposts have no place in it. (REPLY_KINDS, above: a note or a comment.)
+ const feedKinds = (def) => (def && def.replies ? REPLY_KINDS : FEED_KINDS);
+ const feedKeeps = (def, e) => (def && def.replies ? replyish(e) : FEED_KINDS.includes(e.kind) && !isReply(e));
+ // What a reply answers: the e tag marked 'reply' (NIP-10), else 'root'
+ // (a reply straight to the root), else — the deprecated positional form —
+ // the last e tag; a NIP-22 comment's lowercase e (its parent), else its E.
+ function parentRefOf(ev) {
+ if (!ev || !replyish(ev)) return null;
+ const es = (ev.tags || []).filter((x) => x[0] === 'e' && x[1]);
+ let tag;
+ if (ev.kind === 1111) tag = es[0] || (ev.tags || []).find((x) => x[0] === 'E' && x[1]);
+ else tag = es.find((x) => x[3] === 'reply') || es.find((x) => x[3] === 'root') || es[es.length - 1];
+ if (!tag) return null;
+ const ref = { type: 'event', id: tag[1], relays: tag[2] ? [tag[2]] : [] };
+ if (/^[0-9a-f]{64}$/i.test(tag[4] || '')) ref.author = tag[4].toLowerCase();
+ return ref;
+ }
+ // A note already in hand — in a feed, a thread, someone's page — before
+ // asking any relay for it: the post a reply answers is usually right
+ // there in the Following feed.
+ function noteInHand(id) {
+ for (const c of feedStates.values()) for (const e of c.notes) {
+ if (e.id === id) return e;
+ if (e.kind === REPOST) { const inner = repostInner(e); if (inner && inner.id === id) return inner; }
+ }
+ for (const c of threadCache.values()) {
+ if (c.root && c.root.id === id) return c.root;
+ for (const e of c.replies || []) if (e.id === id) return e;
+ }
+ for (const c of notesCache.values()) for (const e of c.notes || []) if (e.id === id) return e;
+ return null;
+ }
function notesFor(pk) {
let c = notesCache.get(pk);
if (c) {
@@ -4767,6 +4855,7 @@ export function messagesFeature(ctx) {
// DOWN, and that must not count as having gone up to look at them.
const y = window.scrollY || 0, up = y < lastFeedY;
lastFeedY = y;
+ if (!ui.profilePk && Date.now() - lastInputAt < 2500) noteScrollTrail(y); // the reader's own travel (not a held-still insert)
// Scrolling recounts the pill: posts you've gone up past drop off it,
// and once none are left above you the notice is done.
if (!ui.profilePk && feed && feed.ahead?.size && up && !pillFrame) pillFrame = requestAnimationFrame(() => {
@@ -4779,7 +4868,7 @@ export function messagesFeature(ctx) {
else if (n !== feed.pillN) render();
});
if (!ui.profilePk && feed?.deferred?.length) admitFeed(feed.deferred, feed);
- if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : Math.max(2400, window.innerHeight * 3))) return;
+ if (window.innerHeight + window.scrollY < (document.documentElement.scrollHeight || 0) - (ui.profilePk ? 600 : feedLead())) return;
if (ui.profilePk) loadOlderNotes(ui.profilePk).catch(() => {});
else loadOlderFeed().catch(() => {});
}, { passive: true });
@@ -5169,6 +5258,13 @@ export function messagesFeature(ctx) {
// Two feeds everyone has: the people you follow, and everything the coinos
// relay carries. Each remembers its own relay choice in the state.
const EVERYTHING = 'all';
+ // The Conversations view of a feed (the chip beside Notes / Images /
+ // Videos / Articles): the same people, their replies — a feed state of
+ // its own under the feed's id with this suffix, its definition derived
+ // from the feed's (replies: true, see feedKeeps). Nothing is stored
+ // under it but its notes cache.
+ const VIEW_SUFFIX = '~replies';
+ const viewId = (id = curFeedId) => (mediaMode === 'replies' ? id + VIEW_SUFFIX : id);
// Amethyst's feed model, for the people who asked: everyone you follow plus
// everyone on your lists, and on top of that posts from anyone on a
// hashtag you follow — your topic feeds' tags and your NIP-51 interests
@@ -5225,6 +5321,10 @@ export function messagesFeature(ctx) {
...st().feeds.map(expandFeed)];
};
function feedDef(id = curFeedId) {
+ if (typeof id === 'string' && id.endsWith(VIEW_SUFFIX)) {
+ const base = feedDef(id.slice(0, -VIEW_SUFFIX.length));
+ return base ? { ...base, id, replies: true, view: true } : null;
+ }
if (id === FOLLOWING || id === EVERYTHING) return feedList().find((f) => f.id === id) || null;
const own = st().feeds.find((f) => f.id === id);
return own ? expandFeed(own) : adhocFeeds.get(id) || null;
@@ -5837,15 +5937,16 @@ export function messagesFeature(ctx) {
function feedNow() {
if (!feedDef(curFeedId)) curFeedId = EVERYTHING; // a feed deleted on another device
- let c = feedStates.get(curFeedId);
+ const id = viewId(); // the feed, or its Conversations view
+ let c = feedStates.get(id);
if (!c) {
let stored = [];
- try { stored = feedDisk()?.read(curFeedId) || wallet.loadFeatureState(feedCacheKey(curFeedId), []) || []; } catch {}
+ try { stored = feedDisk()?.read(id) || wallet.loadFeatureState(feedCacheKey(id), []) || []; } catch {}
if (!Array.isArray(stored)) stored = [];
- if (!stored.length) stored = borrowedNotes(feedDef(curFeedId));
+ if (!stored.length) stored = borrowedNotes(feedDef(id));
stored = stored.slice(0, FEED_STORE);
- c = { id: curFeedId, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() };
- feedStates.set(curFeedId, c);
+ c = { id, status: 'loading', notes: stored, shown: FEED_PAGE, at: 0, booting: true, presentations: new Map() };
+ feedStates.set(id, c);
while (feedStates.size > 4) {
const oldest = feedStates.keys().next().value;
feedStates.get(oldest).stopped = true;
@@ -5854,14 +5955,14 @@ export function messagesFeature(ctx) {
feed = c;
// Only the opening page gates first paint. The next page warms on the
// side, so an offscreen image cannot delay the whole feed.
- c.boot = notesReady(stored.slice(0, FEED_PAGE)).finally(() => {
+ c.boot = notesReady(stored.slice(0, FEED_PAGE), { parent: feedReplies(c) }).finally(() => {
c.booting = false;
c.prepared = null;
if (!c.stopped && ui.chatOpen && ui.msgView === 'feed' && c === feed) scheduleRepaint();
});
c.boot.then(() => {
if (c.stopped) return;
- notesReady(stored.slice(FEED_PAGE, FEED_PAGE * 2));
+ notesReady(stored.slice(FEED_PAGE, FEED_PAGE * 2), { parent: feedReplies(c) });
});
prefetchFeed(c);
}
@@ -5905,8 +6006,8 @@ export function messagesFeature(ctx) {
const authors = new Set(feedAuthors(def)), topics = new Set(feedTopics(def)), orT = new Set(orTopicsOf(def));
if (!authors.size && !topics.size) return [];
const tagged = (e, set) => (e.tags || []).some((x) => x[0] === 't' && set.has(normTopic(x[1])));
- const fits = (e) => ((!authors.size || authors.has(e.pubkey))
- && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT));
+ const fits = (e) => feedKeeps(def, e) && (((!authors.size || authors.has(e.pubkey))
+ && (!topics.size || tagged(e, topics))) || (orT.size && tagged(e, orT)));
const seen = new Set(), out = [];
for (const o of feedStates.values()) {
for (const e of o.notes) if (!seen.has(e.id) && fits(e) && !hidden(e)) { seen.add(e.id); out.push(e); }
@@ -5928,10 +6029,10 @@ export function messagesFeature(ctx) {
if (!authors.length && !topics.length) continue;
try {
const events = (await Promise.all((authors.length ? authorChunks(authors) : [null]).map((chunk) =>
- queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: FEED_KINDS, limit: FEED_SEED_POSTS * 2,
+ queryOn(feedRelaysOr(def, NOTE_RELAYS), { kinds: feedKinds(def), limit: FEED_SEED_POSTS * 2,
...(chunk ? { authors: chunk } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000)))).flat();
if (feedStates.has(def.id)) continue; // opened while we were asking
- disk.seed(def.id, events.filter((e) => FEED_KINDS.includes(e.kind) && !isReply(e) && !hidden(e)));
+ disk.seed(def.id, events.filter((e) => feedKeeps(def, e) && !hidden(e)));
} catch {}
}
}
@@ -6040,7 +6141,7 @@ export function messagesFeature(ctx) {
if (!def) return null;
const r = feedRelays(def) ? '?r=' + encodeURIComponent(feedRelays(def).join(',')) : '';
if (def.all) return (def.curated ? '/feed' : '/feed/all') + r;
- if (def.builtin) return share && mePk() ? '/feed/' + npubOf(mePk()) + r : null;
+ if (def.builtin) return share && mePk() && !def.replies ? '/feed/' + npubOf(mePk()) + r : null;
if (def.of) return '/feed/' + npubOf(def.of) + r;
const authors = def.authors || [], topics = feedTopics(def), packs = def.packs || [];
if (!authors.length && !packs.length && topics.length === 1) return '/feed/t/' + encodeURIComponent(topics[0]) + r;
@@ -6063,7 +6164,9 @@ export function messagesFeature(ctx) {
// beside it). Changing it rebuilds the feed from those relays.
// Forget a feed's state so it is rebuilt; a fetch still in flight for the
// old one lands nowhere (it would otherwise fill the orphan, not the new).
- const dropFeedState = (id) => { const c = feedStates.get(id); if (c) c.stopped = true; feedStates.delete(id); };
+ const dropFeedState = (id) => {
+ for (const k of [id, id + VIEW_SUFFIX]) { const c = feedStates.get(k); if (c) c.stopped = true; feedStates.delete(k); }
+ };
// Popular's defaults before 2026-10-04: a list saved that matches them
// exactly was the defaults, kept by an edit, and follows the new ones.
function oldPopularDefault(list) {
@@ -6677,12 +6780,21 @@ export function messagesFeature(ctx) {
await warmMedia(avatarUrl(p, pk));
if (p?.picture) makeThumb(pk, p);
}
- async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0) {
+ // parent: the post a reply answers is part of the row (a conversations
+ // feed), so it arrives with the row; elsewhere a reply's parent is fetched
+ // only where it is shown (a profile's Replies tab fills its cards in).
+ async function noteReady(ev, deadline = Date.now() + READY_MS, depth = 0, { parent: withParent = false } = {}) {
if (ev && ev.kind === REPOST) { const inner = repostInner(ev); if (!inner) return; ev = inner; }
const tasks = [warmAvatar(ev.pubkey, deadline), ...noteMediaUrls(ev.content).map((u) => warmMedia(u, { thumb: true })),
...(learnVideoMeta(ev), noteVideoUrls(ev.content).map((u) => warmVideo(u, imetaDim(ev, u)))),
...notePreviewUrls(ev.content).map(warmPreview),
...[...emojiTagMap(ev.tags).values()].map(warmMedia)];
+ // a reply's parent, shown above it where replies are shown with context
+ const parent = withParent && !depth ? parentRefOf(ev) : null;
+ if (parent) {
+ const quote = quotedNote(parent);
+ tasks.push(Promise.resolve(quote.promise).then(() => quote.ev && noteReady(quote.ev, deadline, depth + 1)));
+ }
for (const part of String(ev.content || '').split(NOTE_SPLIT)) {
if (/^(nostr:|@?)(npub|nprofile)1/i.test(part)) {
const ref = parseNostrRef(part.replace(/^(nostr:|@)/i, ''));
@@ -6700,7 +6812,7 @@ export function messagesFeature(ctx) {
await Promise.race([Promise.all(tasks), new Promise((r) => { timer = setTimeout(r, Math.max(0, deadline - Date.now())); })]);
} catch {} finally { clearTimeout(timer); }
}
- const notesReady = (evs) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline))); };
+ const notesReady = (evs, opts) => { const deadline = Date.now() + READY_MS; return Promise.all(evs.map((e) => noteReady(e, deadline, 0, opts))); };
const keyed = (node, key) => { node.setAttribute('data-key', key); return node; };
// Keep only the resources this row actually uses. Copying the entire
// session media cache into every row grows quadratically as you scroll.
@@ -6709,6 +6821,9 @@ export function messagesFeature(ctx) {
if (!feedPaint.media.has(url)) feedPaint.media.set(url, mediaReady.get(url) || null);
return feedPaint.media.get(url);
}
+ // a conversations feed: each reply under the post it answers (remembered
+ // on the feed's state; a changed definition rebuilds the state)
+ const feedReplies = (c) => (c.replies ??= !!(feedDef(c.id) || {}).replies);
function feedRow(c, ev) {
let presentation = c.presentations.get(ev.id);
if (!presentation) {
@@ -6718,7 +6833,7 @@ export function messagesFeature(ctx) {
const prev = feedPaint;
feedPaint = presentation;
try {
- const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id);
+ const row = keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey), { context: feedReplies(c) }), ev.id);
if (c.fresh && c.fresh.has(ev.id)) { row.classList.add('note-fresh'); watchFresh(c); }
return row;
}
@@ -6845,10 +6960,10 @@ export function messagesFeature(ctx) {
const addrNew = (e) => { const n = noteOf(e); const a = n && addrOf(n); return !a || (!notesIn.has(a) && !staged.has(a) && !!staged.add(a)); };
const fresh = (e) => { const n = noteOf(e); return !!n && !notesIn.has(n.id) && !staged.has(n.id); };
const unstage = (e) => { staged.delete(e.id); const n = noteOf(e); if (n) { staged.delete(n.id); const a = addrOf(n); if (a) staged.delete(a); } };
- let add = (evs || []).filter((e) => (e.kind === REPOST ? !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : FEED_KINDS.includes(e.kind) && !isReply(e)) && !hidden(e)
+ const def = feedDef(c.id);
+ let add = (evs || []).filter((e) => (e.kind === REPOST ? !(def && def.replies) && !!repostInner(e) && !hidden(repostInner(e)) && !reboosted(e) : feedKeeps(def, e)) && !hidden(e)
&& fresh(e) && !known.has(e.id) && !staged.has(e.id) && addrNew(e) && known.add(e.id) && staged.add(e.id) && staged.add(noteOf(e).id));
if (!add.length) return false;
- const def = feedDef(c.id);
if (def && def.curated) {
const kept = curateFeed(add);
for (const e of add) if (!kept.includes(e)) unstage(e);
@@ -6863,7 +6978,7 @@ export function messagesFeature(ctx) {
c.booting = true;
for (const e of add) unstage(e);
admitFeed(add, c);
- c.boot = notesReady(c.notes.slice(0, FEED_PAGE)).finally(() => {
+ c.boot = notesReady(c.notes.slice(0, FEED_PAGE), { parent: feedReplies(c) }).finally(() => {
c.booting = false;
c.prepared = null;
if (!c.stopped && c === feed) scheduleRepaint();
@@ -6874,7 +6989,7 @@ export function messagesFeature(ctx) {
// Each row waits only for its own presentation. A slow photo or quote
// cannot hold ready text posts (or their new-post notice) behind it.
const ready = await Promise.all(add.map(async (e) => {
- await noteReady(e);
+ await noteReady(e, undefined, 0, { parent: feedReplies(c) });
unstage(e);
if (c.stopped) return false;
// Cached rows can still be warming. This new row is already prepared
@@ -6937,7 +7052,7 @@ export function messagesFeature(ctx) {
}
const result = mergeFeedWindow(c.notes, c.shown, add, {
rows: rows.map((r) => ({ id: r.getAttribute('data-key'), top: r.getBoundingClientRect().top, bottom: r.getBoundingClientRect().bottom })),
- height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE,
+ height: typeof window === 'undefined' ? 0 : window.innerHeight, keep: FEED_KEEP, page: FEED_PAGE * (1 + APPETITE_MAX), // room for the pass fetched ahead
holdHead: headBusy(),
});
c.deferred = result.deferred;
@@ -7127,6 +7242,7 @@ export function messagesFeature(ctx) {
async function feedPass(extra = {}, merge = {}, c = feedNow(), kinds = FEED_KINDS, limit = FEED_LIMIT) {
const def = feedDef(c.id);
if (!def) return false;
+ if (kinds === FEED_KINDS) kinds = feedKinds(def); // a conversations feed asks for replies
const authors = feedAuthors(def), topics = feedTopics(def);
const tag = topics.length ? { '#t': topics } : {};
let got = false;
@@ -7354,22 +7470,23 @@ export function messagesFeature(ctx) {
// the same relays the pass above reads, one subscription each.
function watchFeed() {
stopFeedWatch();
- const def = feedDef();
- if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return;
const c = feedNow();
+ const def = feedDef(c.id); // the Conversations view has its own
+ if (!feedHasQuery(def) || !ui.chatOpen || ui.msgView !== 'feed') return;
const newerThan = c.notes.length ? c.notes[0].created_at : Infinity;
const since = Math.floor(Date.now() / 1000) - 60;
const topics = feedTopics(def), tag = topics.length ? { '#t': topics } : {};
+ const kinds = feedKinds(def);
const on = (ev) => { mergeFeed([ev], { live: true, newerThan }, c).then((ok) => { if (ok) scheduleRepaint(); }).catch(() => {}); };
const authors = feedAuthors(def);
if (def.curated) { const iv = setInterval(() => { if (c === feed && ui.chatOpen && ui.msgView === 'feed') refreshFeed({ force: true, live: true }, c).catch(() => {}); }, 120_000); feedUnsubs.push(() => clearInterval(iv)); return; }
- if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds: FEED_KINDS, ...tag, since }, on)); return; }
+ if (!authors.length) { feedUnsubs.push(subscribeOn(feedRelaysOr(def, def.all ? FIREHOSE_RELAYS : TOPIC_RELAYS), { kinds, ...tag, since }, on)); return; }
const orT = orTopicsOf(def);
- if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds: FEED_KINDS, '#t': orT, since }, on));
+ if (orT.length) feedUnsubs.push(subscribeOn(feedRelaysOr(def, TOPIC_RELAYS), { kinds, '#t': orT, since }, on));
const plan = feedRelays(def) ? [{ relays: feedRelays(def), authors }] : outboxPlan(authors);
for (const { relays, authors: a } of plan)
for (let i = 0; i < a.length; i += REQ_AUTHORS)
- feedUnsubs.push(subscribeOn(relays, { kinds: topics.length ? FEED_KINDS : [...FEED_KINDS, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
+ feedUnsubs.push(subscribeOn(relays, { kinds: topics.length || def.replies ? kinds : [...kinds, REPOST], authors: a.slice(i, i + REQ_AUTHORS), ...tag, since }, on));
}
function stopFeedWatch() {
for (const u of feedUnsubs) { try { u(); } catch {} }
@@ -7383,43 +7500,98 @@ export function messagesFeature(ctx) {
feedAheadTimer = setTimeout(() => {
feedAheadTimer = null;
if (c !== feed || !ui.chatOpen || ui.msgView !== 'feed' || ui.profilePk || ui.noteThread) return;
- if (mediaMode !== 'all') {
+ if (!listView()) {
// a grid holds only the posts with media: keep fetching while its
// foot is near (within reason — a firehose has no end)
const g = document.querySelector(mediaMode === 'articles' ? '.article-list' : '.media-grid');
if (!g || g.getBoundingClientRect().bottom < window.innerHeight * 3) (mediaMode === 'articles' ? crawlArticles : crawlMedia)(c).catch(() => {});
return;
}
+ fetchOlderAhead(c); // the relays, meanwhile, if what we hold beyond the rows is thin
if (c.win && c.win.bottom) return; // rows below are held back by the window, not by paging
const rows = document.querySelectorAll('.notes-feed > .row[data-key]');
const last = rows[rows.length - 1];
- if (last && last.getBoundingClientRect().bottom < window.innerHeight * 4) loadOlderFeed().catch(() => {});
+ if (last && last.getBoundingClientRect().bottom < feedLead()) loadOlderFeed().catch(() => {});
}, 100);
}
+ // ---- the reader's appetite -------------------------------------------
+ // How hungrily the feed is being scrolled: the screens travelled, by the
+ // reader's own hand, over the last ten seconds. A page of posts is read
+ // at a screen or two in that time; five screens in it is someone flying
+ // through, and the feed answers by paging more posts at a time and
+ // asking for them from further up — up to four pages and four times the
+ // lead at once. (Adam, 2026-10-16: "could we load posts faster if it
+ // seems like the user is scrolling a lot? … try always loading more
+ // posts and loading them sooner when we're further away from the
+ // bottom".) The trail counts only scrolls within a moment of an input,
+ // so a page held still under an insert above doesn't look like travel.
+ const APPETITE_MAX = 4, APPETITE_MS = 10_000, APPETITE_SCREENS = 5;
+ const scrollTrail = []; // [at, scrollY]
+ function noteScrollTrail(y) {
+ const now = Date.now();
+ scrollTrail.push([now, y]);
+ while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift();
+ }
+ function appetite() {
+ const now = Date.now();
+ while (scrollTrail.length && now - scrollTrail[0][0] > APPETITE_MS) scrollTrail.shift();
+ if (scrollTrail.length < 2) return 1;
+ const H = (typeof window !== 'undefined' && window.innerHeight) || 800;
+ const down = scrollTrail[scrollTrail.length - 1][1] - scrollTrail[0][1];
+ return Math.max(1, Math.min(APPETITE_MAX, 1 + Math.floor(down / (APPETITE_SCREENS * H))));
+ }
+ // how far above the foot of the feed the next page is asked for
+ const feedLead = () => Math.max(3000, ((typeof window !== 'undefined' && window.innerHeight) || 800) * 4) * appetite();
+ // The relays run AHEAD of the rows. The older page used to be asked for
+ // only once every post in hand was on screen, and shown only once the
+ // whole pass had answered — so the reader who reached the end of what we
+ // held waited a relay round trip, up to the five seconds the slowest
+ // relay in the pass is given. Now a pass for older posts starts as soon
+ // as what we hold beyond the shown rows thins to a page or two (more
+ // with the appetite), its rows warming at the door while the reader is
+ // still on the posts before them; the showing step takes from hand.
+ function fetchOlderAhead(c) {
+ if (c.fetching) return c.fetching;
+ if (c.end || c.booting || c.stopped || c.status !== 'ready') return null;
+ if (c.notes.length - c.shown >= FEED_PAGE * (1 + appetite())) return null;
+ const oldest = c.notes.at(-1);
+ if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return null; }
+ c.fetching = (async () => {
+ let until = oldest.created_at - 1;
+ const limit = Math.min(100, FEED_LIMIT * appetite()); // a hungrier reader is fetched more per round trip
+ for (let tries = 0; tries < 3; tries++) {
+ c.sawAny = 0;
+ if (await feedPass({ until }, {}, c, FEED_KINDS, limit)) return true;
+ // nothing older at all (or no step back): that really is the end
+ if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return false; }
+ until = c.seenBack - 1;
+ }
+ return false;
+ })().catch(() => false).finally(() => {
+ c.fetching = null;
+ if (c === feed && !c.stopped) { scheduleRepaint(); prepareFeedAhead(c); }
+ });
+ return c.fetching;
+ }
async function loadOlderFeed() {
const c = feedNow();
if (c.booting || c.loadingMore || (c.shown >= c.notes.length && (c.end || c.status !== 'ready'))) return;
c.loadingMore = true;
if (c === feed) render(); // the footer says so while it happens
try {
- if (c.shown < c.notes.length) {
- const end = Math.min(c.shown + FEED_PAGE, c.notes.length);
- const boundary = c.notes[end - 1].id;
- await notesReady(c.notes.slice(c.shown, Math.min(end + FEED_PAGE, c.notes.length)));
- c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1);
- return;
- }
- if (c.status !== 'ready' || c.end) return;
- const oldest = c.notes.at(-1);
- if (!oldest || !feedHasQuery(feedDef(c.id))) { c.end = true; return; }
- let until = oldest.created_at - 1;
- for (let tries = 0; tries < 3; tries++) {
- c.sawAny = 0;
- if (await feedPass({ until }, {}, c)) { c.shown = Math.min(c.shown + FEED_PAGE, c.notes.length); return; }
- // nothing older at all (or no step back): that really is the end
- if (!c.sawAny || !(c.seenBack <= until)) { c.end = true; return; }
- until = c.seenBack - 1;
+ fetchOlderAhead(c);
+ if (c.shown >= c.notes.length) {
+ // nothing in hand: the pass underway (started just now, if need be) brings the next page
+ if (!c.fetching) return;
+ await c.fetching;
+ if (c.shown >= c.notes.length) return;
}
+ const page = FEED_PAGE * appetite();
+ const end = Math.min(c.shown + page, c.notes.length);
+ const boundary = c.notes[end - 1].id;
+ await notesReady(c.notes.slice(c.shown, Math.min(end + page, c.notes.length)), { parent: feedReplies(c) });
+ c.shown = Math.max(c.shown, c.notes.findIndex((e) => e.id === boundary) + 1);
+ fetchOlderAhead(c); // and the relays, if that thinned what we hold
} finally {
c.loadingMore = false;
if (c === feed) render();
@@ -7940,7 +8112,11 @@ export function messagesFeature(ctx) {
function quotedNote(ref) {
let c = quoted.get(ref.id);
if (c && (c.status !== 'missing' || Date.now() - (c.at || 0) < QUOTE_RETRY_MS)) return c;
- if (!c) { c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c); }
+ if (!c) {
+ const have = noteInHand(ref.id);
+ if (have) { c = { status: 'ready', ev: have, at: Date.now(), promise: Promise.resolve() }; quoted.set(ref.id, c); return c; }
+ c = { status: 'loading', ev: null, at: 0 }; quoted.set(ref.id, c);
+ }
c.at = Date.now();
c.promise = (async () => {
c.ev = (await findNote(ref).catch(() => null)) || c.ev || null;
@@ -9579,6 +9755,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,22 +10101,28 @@ 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'))));
}
- function noteRow(pk, ev, name, { open = true, focus = false, joined = false } = {}) {
+ // context: a reply shows the post it answers above itself (the
+ // Conversations feed, a profile's Replies tab) — a quote card, which opens
+ // that post; the reply's own tap opens the thread at the reply.
+ function noteRow(pk, ev, name, { open = true, focus = false, joined = false, context = false } = {}) {
if (ev.kind === REPOST) {
const inner = repostInner(ev);
if (!inner) return h('div', { class: 'row note-post', style: 'display:none' });
const prev = repostBy;
repostBy = ev.pubkey;
- try { return noteRow(inner.pubkey, inner, displayName(inner.pubkey), { open, focus, joined }); }
+ try { return noteRow(inner.pubkey, inner, displayName(inner.pubkey), { open, focus, joined, context }); }
finally { repostBy = prev; }
}
// who reposted this, for its header line (only this row: a quote inside draws none)
@@ -9928,6 +10147,7 @@ export function messagesFeature(ctx) {
const foreign = ev.kind !== 1 && ev.kind !== ARTICLE && ev.kind !== POLL && ev.kind !== COMMENT && !isVideoEvent(ev) && !zapRec;
const altText = foreign ? ((ev.tags.find((x) => x[0] === 'alt') || [])[1] || t('noteForeignKind', { kind: ev.kind })) : null;
const isReply = ev.kind === COMMENT || (!foreign && !zapRec && ev.tags.some((x) => x[0] === 'e'));
+ const parent = context && !foreign && !zapRec ? parentRefOf(ev) : null;
const canZap = canZapPk(pk);
// an optimistic post mid-publish: visible but not yet a real event —
// dimmed, and no thread/reply/zap until its signed self takes over
@@ -9967,6 +10187,9 @@ export function messagesFeature(ctx) {
h('div', { class: 'note-content' },
reposter ? h('div', { class: 'small faint note-reposted', onClick: (e) => { e.stopPropagation(); openProfile(reposter); } },
h('span', { 'aria-hidden': 'true' }, '\u{1F501} '), t('postRepostedBy', { name: displayName(reposter) })) : null,
+ parent ? h('div', { class: 'note-context', 'data-context-of': parent.id },
+ h('div', { class: 'small faint note-context-label' }, '\u21a9 ' + t('noteReplyingTo')),
+ quoteCard(parent, 0)) : null,
h('div', { class: 'row between note-header', style: 'align-items:center;gap:8px' },
h('div', { class: 'row', style: 'gap:7px;align-items:baseline;min-width:0' },
h('span', {
@@ -9976,7 +10199,7 @@ export function messagesFeature(ctx) {
onClick: (e) => { e.stopPropagation(); openProfile(pk); },
}, name),
h('span', { class: 'small faint', style: 'white-space:nowrap' },
- (isReply ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)
+ (isReply && !parent ? '↩ ' + t('profReplyTag') + ' · ' : '') + timeLabel(ev.created_at * 1000)
+ (edited ? ' · ' + t('postEditedTag') : ''))),
pending ? null : h('button', {
// the overflow stays up here; reply, boost, quote, react and zap
@@ -10090,6 +10313,7 @@ export function messagesFeature(ctx) {
.find((c) => c.replies.some((e) => e.id === seed.id || e.id === requestedRootId));
if (c) {
threadCache.set(requestedRootId, c);
+ if (adoptRepliesInHand(c) && c.status === 'ready' && !c.prefetched) persistThread(c);
if (!prefetch) {
c.focusId = seed.id;
const wasPrefetched = c.prefetched;
@@ -10123,9 +10347,15 @@ export function messagesFeature(ctx) {
for (const q of stored?.quotes || []) if (!quoted.get(q.id)?.ev) quoted.set(q.id, { status: 'ready', ev: q, at: Date.now() });
const rootId = stored?.rootId || requestedRootId;
c = { status: stored ? 'ready' : 'loading', rootId, focusId: seed.id, prefetched: prefetch,
- root: stored?.root || (seed.id === rootId ? seed : null), replies: stored?.replies || [], counts: stored?.counts || {} };
+ // a root already in hand (the post a conversation row answers sits in
+ // the Following feed) paints at once; the relays only fill the replies
+ root: stored?.root || (seed.id === rootId ? seed : noteInHand(rootId)), replies: stored?.replies || [], counts: stored?.counts || {} };
+ // the replies the feed already fetched for the count go in now: the
+ // thread opens whole for any post that has been on screen
+ adoptRepliesInHand(c);
if (seed.id !== rootId && !c.replies.some((e) => e.id === seed.id)) c.replies.push(seed);
c.replies.sort((a, b) => a.created_at - b.created_at);
+ warmThread([c.root, ...c.replies]);
threadCache.set(requestedRootId, c);
threadCache.set(rootId, c);
if (ui.noteThread?.focusId === seed.id) ui.noteThread.rootId = rootId;
@@ -10141,16 +10371,34 @@ export function messagesFeature(ctx) {
return [...new Set([...hints, ...sets.flat(), ...NOTE_RELAYS])].slice(0, 12);
};
let relays = await relaysFor(c.root || seed);
- const [roots, replies] = await Promise.all([
- c.root ? Promise.resolve([]) : queryOn(relays, { ids: [rootId] }, 4000), // a root can be a poll
+ // Replies go in as they arrive, the page repainted in place (not
+ // after the slowest relay's EOSE, which used to hold the whole
+ // thread behind a spinner); the root the moment it is found.
+ const onScreen = () => ui.noteThread && (ui.noteThread.rootId === c.rootId || ui.noteThread.rootId === requestedRootId);
+ let paintT = null;
+ const paint = () => { if (paintT || !onScreen()) return; paintT = setTimeout(() => { paintT = null; if (onScreen()) renderThreadStable(); }, 150); };
+ const got = new Set(c.replies.map((e) => e.id));
+ const streamed = new Map();
+ const take = (e) => {
+ if (!e || e.id === c.rootId || got.has(e.id)) return;
+ got.add(e.id); noteForSpam(e); streamed.set(e.id, e);
+ // painted whole: its face and pictures first (briefly), then the row
+ warmThread([e], Date.now() + THREAD_ROW_MS).then(() => {
+ if (c.replies.some((x) => x.id === e.id)) return;
+ c.replies = [...c.replies, e].sort((a, b) => a.created_at - b.created_at);
+ paint();
+ });
+ };
+ await Promise.all([
+ c.root ? Promise.resolve() : queryOn(relays, { ids: [rootId] }, 4000).then((roots) => { // a root can be a poll
+ if (!c.root && (roots || [])[0]) { c.root = roots[0]; paint(); }
+ }),
// kind 1 replies, and NIP-22 comments naming it as their root
- Promise.all([
- queryOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, 4500).catch(() => []),
- queryOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, 4500).catch(() => []),
- ]).then(([a, b]) => [...(a || []), ...(b || [])]),
+ queryStreamOn(relays, { kinds: [1], '#e': [rootId], limit: 80 }, take, 4500).catch(() => {}),
+ queryStreamOn(relays, { kinds: [COMMENT], '#E': [rootId], limit: 120 }, take, 4500).catch(() => {}),
]);
- if (!c.root) c.root = (roots || [])[0] || null;
- let all = [...c.replies, ...(replies || [])];
+ clearTimeout(paintT); paintT = null;
+ let all = [...c.replies, ...streamed.values()];
// A client that tags only the note it answered leaves the root
// unnamed: what we resolved as the root is itself a reply. Climb —
// fetch ITS root and re-root the thread there — a few hops at most.
@@ -10329,9 +10577,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 +10750,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;
@@ -10496,8 +10762,10 @@ export function messagesFeature(ctx) {
}
};
walk(c.rootId, 0);
- if (c.status === 'loading') kids.push(h('div', { class: 'row', style: 'justify-content:center;padding:12px' }, h('span', { class: 'spinner sm' })));
- else if (!c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies')));
+ // No spinner: the post reads at once and its replies come in whole as
+ // they arrive (Adam: rather have them pop in than wait to learn there
+ // are none); "no replies" only once the relays have spoken.
+ if (c.status !== 'loading' && !c.replies.length) kids.push(h('div', { class: 'small faint', style: 'text-align:center;padding:10px 0' }, t('threadNoReplies')));
if (!boxPlaced) kids.push(replyBox(false));
// A tapped reply deep in a long thread has to be ON SCREEN when the
// thread opens — highlighting it somewhere below the fold reads as the
@@ -10614,6 +10882,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; }
@@ -11022,7 +11291,7 @@ export function messagesFeature(ctx) {
return h('div', { class: 'col', style: 'gap:8px' },
tabs,
h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-key': 'prof-notes:' + tab },
- ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name), ev.id)))),
+ ...(c.winList = () => c.notes.filter((e) => (profTab(pk) === 'replies') === replyish(e)), windowedRows(c, shown, (ev) => keyed(noteRow(pk, ev, name, { context: profTab(pk) === 'replies' }), ev.id)))),
c.loadingMore
? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' },
h('span', { class: 'spinner sm' }))
@@ -11398,7 +11667,10 @@ export function messagesFeature(ctx) {
// it), built outside the morph so a repaint never restarts a playing clip.
const MEDIA_MODE_KEY = 'coinos-feed-media';
let mediaMode = 'all';
- try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {}
+ try { const m = localStorage.getItem(MEDIA_MODE_KEY); if (m === 'replies' || m === 'images' || m === 'videos' || m === 'articles') mediaMode = m; } catch {}
+ // Notes and Conversations are lists of posts (the feed's own state, or its
+ // Conversations view); the rest are grids read from the media index.
+ const listView = () => mediaMode === 'all' || mediaMode === 'replies';
const mediaKey = (it) => it.ev.id + '|' + it.url;
// the picture an imeta tag names for a clip (NIP-92 `image`), if any
const posterOf = (ev, url) => {
@@ -11423,7 +11695,7 @@ export function messagesFeature(ctx) {
const key = mediaIdxKey(feedId);
let r = mediaIdx.get(key);
if (r) return r;
- r = { key, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false };
+ r = { key, replies: !!(feedDef(feedId) || {}).replies, images: new Map(), videos: new Map(), articles: new Map(), art: { until: 0, end: 0, empty: 0, head: 0 }, checked: new Set(), until: 0, end: false, empty: 0, loaded: false };
mediaIdx.set(key, r);
loadMediaIndex(key).then(async (rec) => {
if (rec) {
@@ -11444,7 +11716,7 @@ export function messagesFeature(ctx) {
await Promise.all([warmVideoThumbs([...r.videos.values()]),
warmTileThumbs([...r.images.values()].flatMap((e) => noteMediaUrls(e.content)))]);
r.loaded = true;
- if (mediaMode !== 'all') scheduleRepaint();
+ if (!listView()) scheduleRepaint();
});
return r;
}
@@ -11462,7 +11734,7 @@ export function messagesFeature(ctx) {
for (const e of evs) {
if (r.checked.has(e.id)) continue;
r.checked.add(e.id);
- if (!FEED_KINDS.includes(e.kind) || isReply(e)) continue;
+ if (r.replies ? !replyish(e) : !FEED_KINDS.includes(e.kind) || isReply(e)) continue;
for (const kind of ['images', 'videos']) if (!r[kind].has(e.id) && hasMediaOf(e, kind)) { r[kind].set(e.id, e); n++; }
}
if (n) saveMediaIdx(r);
@@ -11472,7 +11744,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 +11754,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);
@@ -11616,7 +11887,7 @@ export function messagesFeature(ctx) {
if (res) { videoThumbs.set(url, URL.createObjectURL(await res.blob())); found++; }
}
} catch {}
- if (found && mediaMode !== 'all') scheduleRepaint();
+ if (found && !listView()) scheduleRepaint();
}
function keepVideoThumb(v, url) {
if (videoThumbs.has(url)) return Promise.resolve(true);
@@ -11845,16 +12116,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 +12180,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; }
@@ -11913,6 +12227,9 @@ export function messagesFeature(ctx) {
function setMediaMode(m) {
mediaMode = m;
try { localStorage.setItem(MEDIA_MODE_KEY, m); } catch {}
+ // Notes ↔ Conversations: another feed state (the view's), entered like
+ // a switch of feed — its watch, its fresh counter — with no change of chip
+ if (listView() && feedDef(curFeedId)) selectFeed(curFeedId);
render();
try { window.scrollTo({ top: 0 }); } catch {}
}
@@ -11922,7 +12239,7 @@ export function messagesFeature(ctx) {
onClick: () => setMediaMode(m),
}, label);
return h('div', { class: 'row media-modes', 'data-key': 'media-modes' },
- b('all', t('mediaAll')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles')));
+ b('all', t('mediaAll')), b('replies', t('mediaConversations')), b('images', t('mediaImages')), b('videos', t('mediaVideos')), b('articles', t('mediaArticles')));
}
function mediaGrid(c) {
const items = mediaItems(c, mediaMode);
@@ -12031,33 +12348,42 @@ 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.goBack(() => { ui.mediaPager = null; });
+ const closeMediaPager = () => ctx.closeLayer('mediaPager');
// still on the picture that opened the pager (nothing swiped to yet)
const pagerOnStart = () => !pager || !pager.cur || pager.cur.getAttribute('data-k') === pager.start;
// Dismissing a pager opened from the feed after moving through it lands
// 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 +12394,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 +12434,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 +12462,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 +12475,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 +12506,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 +12529,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 +12618,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 +12812,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 +12840,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 +13005,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 +13045,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 +13083,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 +13115,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 +13129,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 +13140,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 +13177,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 +13187,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);
+ }
+ // 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(':'),
+ };
}
- // 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;
+ 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 +13258,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 +13270,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 +13295,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(); }
@@ -12785,8 +13324,8 @@ export function messagesFeature(ctx) {
const visitor = isVisitor();
queueMicrotask(() => {
syncMediaPager();
- if (mediaMode === 'all') watchSeen();
- if (mediaMode !== 'all') watchMediaTiles();
+ if (listView()) watchSeen();
+ else watchMediaTiles();
});
const authors = feedAuthors(def);
const hasQuery = feedHasQuery(def);
@@ -12813,7 +13352,7 @@ export function messagesFeature(ctx) {
const visNotes = c.notes, visShown = c.shown;
c.winList = () => (c.notes === visNotes && c.shown === visShown ? list : c.notes.filter(shownHere).slice(0, c.shown || FEED_PAGE));
const rows = windowedRows(c, list, (ev) => (c.booting && !c.prepared?.has(ev.id)
- ? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id) : feedRow(c, ev)));
+ ? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey), { context: feedReplies(c) }), ev.id) : feedRow(c, ev)));
// Posts that went in above you while you were reading. A floating pill
// that says how many are up there; the tap takes you up to them.
// Keyed, so the morph keeps this very node while the count changes —
@@ -12883,7 +13422,7 @@ export function messagesFeature(ctx) {
h('div', { class: 'small muted' }, t('feedNoFollows')),
h('button', { class: 'btn-sm', onClick: () => { stopFeedWatch(); hook('openUserSearch'); render(); } }, t('feedFindPeople')),
// your own posts, under the invitation
- noFollows && visible.length && mediaMode === 'all' ? h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows) : null)
+ noFollows && visible.length && listView() ? h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows) : null)
: h('div', { class: 'col', style: 'gap:8px' },
h('div', { class: 'small muted' }, t('feedNoQuery')),
h('button', { class: 'btn-sm', onClick: () => openFeedEditor(def) }, t('feedEdit')))
@@ -12892,16 +13431,16 @@ export function messagesFeature(ctx) {
: !visible.length
? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty'))
: mediaMode === 'articles' ? articleList(c)
- : mediaMode !== 'all' ? mediaGrid(c)
- : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined }, ...rows),
+ : !listView() ? mediaGrid(c)
+ : h('div', { class: 'card col notes-feed', style: 'gap:0', 'data-booting': c.booting ? '1' : undefined, 'data-shown': c.shown, 'data-held': c.notes.length }, ...rows),
// The foot of the feed says what is happening down there: older
// posts on their way, or nothing older left to fetch. (Older pages
// prepare offscreen, so the line is mostly seen by a reader who
// outruns them.)
- c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : mediaMode !== 'all' && mediaCrawling)
+ c.loadingMore || (mediaMode === 'articles' ? articlesCrawling && mediaIndex(c.id).articles.size : !listView() && mediaCrawling)
? h('div', { class: 'row gap6 feed-foot', style: 'justify-content:center;align-items:center;padding:10px 0' },
h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('feedLoadingMore')))
- : mediaMode !== 'all' ? ((mediaMode === 'articles' ? mediaIndex(c.id).art.end : mediaIndex(c.id).end) ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null)
+ : !listView() ? ((mediaMode === 'articles' ? mediaIndex(c.id).art.end : mediaIndex(c.id).end) ? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd')) : null)
: c.end && visible.length && !(c.win && c.win.bottom) && c.shown >= visible.length
? h('div', { class: 'small faint feed-foot', style: 'text-align:center;padding:10px 0' }, t('feedEnd'))
: null,
@@ -12962,7 +13501,7 @@ export function messagesFeature(ctx) {
save(s);
feedsChanged();
if (e.id && adhocFeeds.has(e.id)) adhocFeeds.delete(e.id);
- feedStates.delete(id); // its query changed: rebuild from scratch
+ dropFeedState(id); // its query changed: rebuild from scratch
ui.feedEdit = null;
switchFeed(id);
// the list side: a feed of people goes out as a follow set (or, unticked,
@@ -13003,7 +13542,7 @@ export function messagesFeature(ctx) {
save(s);
feedsChanged();
if (def && def.d) deleteFollowSet(def).catch(() => {}); // after ours: it re-reads the state
- feedStates.delete(id);
+ dropFeedState(id);
try { wallet.saveFeatureState(feedCacheKey(id), []); } catch {}
ui.feedEdit = null;
if (curFeedId === id) switchFeed(FOLLOWING); else render();
@@ -14743,10 +15282,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 +15416,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 +15466,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