diff options
Diffstat (limited to 'src/features/messages.js')
| -rw-r--r-- | src/features/messages.js | 1477 |
1 files changed, 1187 insertions, 290 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index a8815b0..3789f93 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -17,6 +17,7 @@ import { npubOf, neventOf, parseNostrPubkey, parseNostrRef, generateSecretKey, getPublicKey, finalizeEvent, nip44, PROFILE_RELAYS, openWrapsOffthread, unwrapDMsOffthread, verifyEventsAsync, setSyncConfig, DEFAULT_SYNC_RELAYS, } from '../nostr.js'; +import { reconcileOn, syncOn, negentropyOff } from '../nostr.js'; // NIP-77: fetch only what we lack import { channelKey, channelStream, channelEpoch, channelIsPrivate, controlKey, guestbookKey, openWrap, wrapRumor, rumorWithId, foldControl, foldGuestbook, observeAuthor, eventMs, msTags, makeEdition, @@ -169,6 +170,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 +183,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 +463,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); }; @@ -682,23 +689,57 @@ export function messagesFeature(ctx) { healSince = 0; } const healing = (wrap) => !!healSince && wrap.created_at >= healSince && !healed.has(wrap.id) && !!healed.add(wrap.id); - function rememberWrap(id) { - const k = wrapKey(id); - if (!wrapLog) wrapLog = []; - if (!seenWraps.has(k)) wrapLog.push(k); - seenWraps.add(k); + // The wraps this device holds — full id and time — for reconciliation + // (NIP-77) with a relay that speaks it: a subscribe then fetches only the + // wraps we lack instead of the relay's newest two hundred (half a + // megabyte, every boot and every resubscribe), and the live subscription + // opens with limit 0, nothing re-served. The prefix log above stays the + // "skip this one" memory; a wrap it knows but this list doesn't (logged + // before the list existed) is added as the relay re-serves it, once. + const WRAP_ITEMS_MAX = 600; // the relay reconciles its newest 400: that many held, with room (46 KB of a phone's near-full localStorage) + let wrapItems = null; // id -> created_at, oldest first + function loadWrapItems() { + if (wrapItems) return; + wrapItems = new Map(); + try { for (const [id, ts] of st().wrapItems || []) if (/^[0-9a-f]{64}$/.test(id)) wrapItems.set(id, ts | 0); } catch {} + } + function noteWrapItem(id, ts) { + if (!id || !ts) return; + loadWrapItems(); + if (wrapItems.has(id)) return; + wrapItems.set(id, ts); + while (wrapItems.size > WRAP_ITEMS_MAX) wrapItems.delete(wrapItems.keys().next().value); + scheduleWrapSave(); + } + const wrapItemsHeld = () => { loadWrapItems(); return [...wrapItems].map(([id, ts]) => [ts, id]); }; + function scheduleWrapSave() { clearTimeout(wrapsTimer); wrapsTimer = setTimeout(() => { try { // another tab may have logged wraps since this one loaded: keep theirs const s = st(); - const mine = new Set(wrapLog); - s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX); - wrapLog = s.wraps; + if (wrapLog) { + const mine = new Set(wrapLog); + s.wraps = [...(s.wraps || []).filter((k) => !mine.has(k)), ...wrapLog].slice(-WRAPS_MAX); + wrapLog = s.wraps; + } + if (wrapItems) { + const all = new Map(s.wrapItems || []); + for (const [id, ts] of wrapItems) all.set(id, ts); + s.wrapItems = [...all].slice(-WRAP_ITEMS_MAX); + } save(s); } catch {} }, 1500); } + function rememberWrap(id, ts) { + const k = wrapKey(id); + if (!wrapLog) wrapLog = []; + if (!seenWraps.has(k)) wrapLog.push(k); + seenWraps.add(k); + if (ts) noteWrapItem(id, ts); + scheduleWrapSave(); + } let dmStarted = false; let allUnsubs = []; let dmUnsubs = []; // the DM wrap subscriptions alone, so they can be rebuilt @@ -3049,13 +3090,14 @@ export function messagesFeature(ctx) { async function handleInboxWrap(wrap) { let heal = false; if (seenWraps.has(wrap.id) || seenWraps.has(wrapKey(wrap.id))) { + noteWrapItem(wrap.id, wrap.created_at); // known by its prefix alone: now held in full if (!healing(wrap)) return; heal = true; } seenWraps.add(wrap.id); const r = await openInboxWrap(wrap, heal); if (r === 'stale') return; - if (r === true) { rememberWrap(wrap.id); return; } + if (r === true) { rememberWrap(wrap.id, wrap.created_at); return; } if (pendingWraps.size >= PENDING_MAX) pendingWraps.delete(pendingWraps.keys().next().value); pendingWraps.set(wrap.id, { wrap, heal, tries: r !== 'later' && decryptorsComplete() ? 1 : 0 }); scheduleDrain(8000); @@ -3095,9 +3137,9 @@ export function messagesFeature(ctx) { if (complete && !decryptorsComplete()) break; const r = await openInboxWrap(p.wrap, p.heal); if (r === 'stale') break; // the account changed under this pass - if (r === true) { pendingWraps.delete(id); rememberWrap(id); } + if (r === true) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); } else if (r === 'later') break; // the signer went quiet — nothing learned, try the rest later - else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id); } + else if (complete && ++p.tries >= PENDING_TRIES) { pendingWraps.delete(id); rememberWrap(id, p.wrap && p.wrap.created_at); } if (performance.now() - chunkStart > 8) { await new Promise((r) => setTimeout(r)); chunkStart = performance.now(); @@ -3154,12 +3196,42 @@ export function messagesFeature(ctx) { } bumpMsgRev(); if (swept) save(s); - const dmSub = (relays) => { - const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, (wrap) => { - handleInboxWrap(wrap).catch(() => {}); - }, { onclose: noteSubClosed }); + const onWrap = (wrap) => { handleInboxWrap(wrap).catch(() => {}); }; + const plainDmSub = (relays) => { + const u = subscribeOn(relays, { kinds: [1059], '#p': pks, limit: 400 }, onWrap, { onclose: noteSubClosed }); allUnsubs.push(u); dmUnsubs.push(u); }; + // Relay by relay. One that speaks negentropy gets the live subscription + // with limit 0 — opened first, so nothing slips between — and a + // reconciliation of the wraps we hold against its newest four hundred, + // the ones we lack fetched by id. One that doesn't (nos.lol has it + // disabled; remembered for a day) is asked the old way, its newest + // wraps re-served and skipped by the prefix log. So is every relay + // while the one-time heal is running: that pass wants them re-served. + const dmSub = (relays) => { + for (const relay of relays) { + if (healSince || negentropyOff(relay)) { plainDmSub([relay]); continue; } + const u = subscribeOn([relay], { kinds: [1059], '#p': pks, limit: 0 }, onWrap, { onclose: noteSubClosed }); + allUnsubs.push(u); dmUnsubs.push(u); + reconcileWraps(relay).then((ok) => { + if (ok || !dmStarted || !dmUnsubs.includes(u)) return; + // it doesn't speak it after all: the plain subscription, with its backlog + try { u(); } catch {} + allUnsubs = allUnsubs.filter((x) => x !== u); dmUnsubs = dmUnsubs.filter((x) => x !== u); + plainDmSub([relay]); + }).catch(() => {}); + } + }; + async function reconcileWraps(relay) { + const r = await reconcileOn(relay, { kinds: [1059], '#p': pks, limit: 400 }, wrapItemsHeld()); + if (!r) return false; + dlog('dm: ' + relay + ' reconciled: ' + r.need.length + ' wraps to fetch, ' + r.have.length + ' held it lacks'); + for (let i = 0; i < Math.min(r.need.length, 600) && dmStarted; i += 100) { + const evs = await queryOn([relay], { ids: r.need.slice(i, i + 100) }, 6000); + for (const w of evs) onWrap(w); + } + return true; + } dmSub(DM_RELAYS); // Senders deliver to the relays our kind-10050 advertises — a list other // clients may have published with relays beyond our defaults. Read those @@ -3416,6 +3488,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 +3660,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 +4078,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 +4489,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 +4552,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 +4819,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 +4921,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 +4934,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 +5324,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 +5387,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 +6003,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 +6021,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 +6072,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 +6095,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 +6207,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 +6230,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 +6846,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 +6878,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 +6887,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 +6899,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 +7026,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 +7044,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 +7055,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 +7118,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 +7308,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 +7536,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 +7566,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(); @@ -7543,6 +7781,9 @@ export function messagesFeature(ctx) { const vmediaOk = (url) => /^https:\/\//i.test(url || ''); // v=4: stills past a dark opening frame (a new URL, so the CDN's old black ones aren't served) const serverThumb = (url) => (vmediaOk(url) ? VMEDIA + 'thumb.jpg?v=4&u=' + encodeURIComponent(url) : undefined); + // the clip's very first frame at full size (the pager's slides ahead hold it) + const serverFirst = (url) => (vmediaOk(url) ? VMEDIA + 'first.jpg?u=' + encodeURIComponent(url) : undefined); + const serverFirstFailed = new Set(); // clips the server had no first frame for (this session) const h264Of = (url) => VMEDIA + 'h264.mp4?u=' + encodeURIComponent(url); const noPicture = new Set(), h264Failed = new Set(); const serverThumbFailed = new Set(); // clips vmedia had no picture for (this session) @@ -7940,7 +8181,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 +9824,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 +10170,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 +10216,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 +10256,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 +10268,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 +10382,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 +10416,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 +10440,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 +10646,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 +10819,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 +10831,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 +10951,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 +11360,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 +11736,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 +11764,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 +11785,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 +11803,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 +11813,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 +11823,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 +11956,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); @@ -11728,6 +12068,68 @@ export function messagesFeature(ctx) { pumpFrames(); return task; } + // ---- the pager's first-frame posters ------------------------------------- + // The clip ahead holds its own first frame — full size, frame 0, drawn + // from the opening of the file (the bytes headStart put in the cache, + // read again by range) — so a flick to it glides onto the very picture + // the player will show first, and the player's frame replaces it with no + // visible change. (Adam, 2026-10-16: "could we actually have the next + // image or video scroll in while the previous scrolls out? Right now it + // goes black before the next one scrolls in.") A dozen are kept, the + // oldest let go; the tile's kept frame (480px, at 0.1 s) is made from the + // same decode when the tile has none yet. + const PAGER_POSTERS = 12, POSTER_W = 1100; + const pagerPosters = new Map(); // url -> object URL, oldest first + const posterGrabs = new Map(); // url -> promise + function pagerPoster(url) { + if (pagerPosters.has(url)) { const p = pagerPosters.get(url); pagerPosters.delete(url); pagerPosters.set(url, p); return Promise.resolve(p); } + if (posterGrabs.has(url)) return posterGrabs.get(url); + const task = probeVideo(url).then(() => grabPoster(url)).catch(() => null).finally(() => posterGrabs.delete(url)); + posterGrabs.set(url, task); + return task; + } + async function grabPoster(url) { + const d = videoInfo.get(url) || {}; + if (!d.probed || !d.faststart || !d.ranges || frameFailed.has(url) || (d.moovEnd || 0) + FRAME_BYTES_AFTER > FRAME_BYTES_MAX) return null; + // no more of the file than the head start fetched: the bytes come from the cache + const last = Math.min((d.moovEnd || 200_000) + FRAME_BYTES_AFTER, Math.max(HEAD_MIN, headStartBytes(url))) - 1; + let r; + try { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, signal: AbortSignal.timeout(20_000) }); } + catch { r = await fetch(url, { headers: { Range: 'bytes=0-' + last }, cache: 'no-store', signal: AbortSignal.timeout(20_000) }); } // a cached copy without CORS headers (Primal's bucket) + if (r.status !== 206 && !(r.ok && d.size && d.size <= last + 1)) { try { r.body.cancel(); } catch {} return null; } + const blob = new Blob([await r.arrayBuffer()], { type: (r.headers.get('content-type') || '').startsWith('video/') ? r.headers.get('content-type') : 'video/mp4' }); + const src = URL.createObjectURL(blob); + const v = document.createElement('video'); + v.muted = true; v.playsInline = true; v.preload = 'auto'; + try { + const shown = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(v.readyState >= 2), 6000); + v.onloadeddata = () => { clearTimeout(timer); resolve(true); }; + v.onerror = () => { clearTimeout(timer); resolve(false); }; + v.src = src; + }); + if (!shown || !v.videoWidth) return null; + const w = Math.min(POSTER_W, v.videoWidth), hh = Math.round(w * v.videoHeight / v.videoWidth); + const cv = document.createElement('canvas'); + cv.width = w; cv.height = hh; + cv.getContext('2d').drawImage(v, 0, 0, w, hh); + const out = await new Promise((resolve) => cv.toBlob(resolve, 'image/jpeg', 0.9)); + if (!out) return null; + const p = URL.createObjectURL(out); + pagerPosters.set(url, p); + while (pagerPosters.size > PAGER_POSTERS) { const k = pagerPosters.keys().next().value; try { URL.revokeObjectURL(pagerPosters.get(k)); } catch {} pagerPosters.delete(k); } + // the tile's frame too, from the same decode, when it has none + if (!videoThumbs.has(url)) { + await new Promise((resolve) => { const t = setTimeout(resolve, 3000); v.onseeked = () => { clearTimeout(t); resolve(); }; try { v.currentTime = 0.1; } catch { clearTimeout(t); resolve(); } }); + await keepVideoThumb(v, url).catch(() => {}); + } + return p; + } finally { + v.onloadeddata = v.onseeked = v.onerror = null; + try { v.removeAttribute('src'); v.load(); } catch {} + URL.revokeObjectURL(src); + } + } function pumpFrames() { while (frameGrabbing < 2 && frameQueue.length) { const { url, resolve } = frameQueue.shift(); @@ -11767,11 +12169,41 @@ export function messagesFeature(ctx) { // stand taller than they're wide. A clip of unknown shape is measured // (the header probe, or a player when the index sits at the end of a // file a server serves in ranges) and joins once it's known to qualify. - function verticalClip(url) { + // ---- which clips fit the screen ----------------------------------------- + // The Videos view and the pager show the clips the screen's way round: tall + // ones on a phone held upright, wide ones held sideways (or on a desktop + // window), a square either way — unless the reader asks for all shapes + // (remembered on this device). (Adam, 2026-10-16: "only show vertical + // videos by default on a phone that's in vertical orientation and only + // horizontal videos if the phone is in landscape but have some kind of + // control to toggle the ability to see all videos".) + const CLIPS_FIT_KEY = 'coinos-clips-fit'; // 'all', or unset: the screen's way round + const clipsAll = () => { try { return localStorage.getItem(CLIPS_FIT_KEY) === 'all'; } catch { return false; } }; + const setClipsAll = (all) => { try { if (all) localStorage.setItem(CLIPS_FIT_KEY, 'all'); else localStorage.removeItem(CLIPS_FIT_KEY); } catch {} }; + const screenTall = () => window.innerHeight >= window.innerWidth; + const clipShape = (url) => { const d = videoInfo.get(url) || {}; const k = d.w && d.h ? d : videoDims.get(url) || mediaReady.get(url); const w = k && (k.w || k.width), hh = k && (k.h || k.height); - if (w && hh) return hh > w; + return w && hh ? (hh > w ? 'tall' : hh < w ? 'wide' : 'square') : null; + }; + // true/false, or null while the clip's shape isn't known + const clipFits = (url) => { if (clipsAll()) return true; const sh = clipShape(url); return sh === null ? null : sh === 'square' || sh === (screenTall() ? 'tall' : 'wide'); }; + const clipsLabel = () => t(clipsAll() ? 'clipsAll' : screenTall() ? 'clipsTall' : 'clipsWide'); + // the toggle: in the pager's top bar and over the Videos grid + function clipsToggle(cls, after) { + return h('button', { class: cls, type: 'button', title: t('clipsToggleTitle'), onClick: (e) => { + e.stopPropagation(); + setClipsAll(!clipsAll()); + for (const b of document.querySelectorAll('.mp-fit, .media-fit-btn')) b.textContent = clipsLabel(); + if (after) after(); + if (mediaMode === 'videos') scheduleRepaint(); + } }, clipsLabel()); + } + function fitClip(url) { + const f = clipFits(url); + if (f !== null) return f; + const d = videoInfo.get(url) || {}; if (!probeFailedLately(d) && !(d.probed && (!d.ranges || videoUnknown.has(url)))) measureClip(url); return false; } @@ -11794,7 +12226,7 @@ export function messagesFeature(ctx) { // a grid shows the picture itself — no warning to fold it behind if (curated ? notForPublic(ev) : modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue; if (kind === 'videos') learnVideoMeta(ev); - const urls = kind === 'videos' ? clipUrlsOf(ev).filter(verticalClip) + const urls = kind === 'videos' ? clipUrlsOf(ev).filter(fitClip) : noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/')); for (const url of urls) { const it = { ev, url }; @@ -11845,16 +12277,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 +12341,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; } @@ -11904,15 +12379,38 @@ export function messagesFeature(ctx) { if (fsElement() === document.documentElement) exitFs(); } // The words of the post, without the links to the media it carries. - function mediaCaption(ev) { + function mediaCaption(ev, raw = false) { let s = isVideoEvent(ev) ? videoEventText(ev) : String(ev.content || ''); s = s.replace(new RegExp(MD_LINK, 'gi'), (m) => (MD_PARTS.exec(m)?.[1] ? '' : m)); for (const u of [...noteMediaUrls(s), ...noteVideoUrls(s)]) s = s.split(u).join(''); - return plainExcerpt(s); + return raw ? s.replace(/\s+/g, ' ').trim() : plainExcerpt(s); + } + // The pager's caption: the post's words with their names, tags and links + // live (a plain excerpt left "@NostrValley" dead — Adam, 2026-10-16). A + // tap on a name, a tag or a note reference leaves the pager, and the full + // screen with it, before going where it goes; a link to the web opens in + // its own tab and the pager stays. A tap anywhere else on the caption + // leaves the pager and opens the post ("clicking anywhere on the caption + // that's not a link should take me out of fullscreen and take me to the + // post"), where the whole of a long caption reads. + function captionNode(ev, text) { + const el = h('div', { class: 'mp-caption' }, ...noteBody(text, 2, noteEmojiTags(ev))); + el.addEventListener('click', (e) => { + const a = e.target && e.target.closest ? e.target.closest('a') : null; + if (a && /^https?:/i.test(a.getAttribute('href') || '')) { e.stopPropagation(); return; } + leavePager(); + if (a) return; // the link's own handler opens the name, the tag, the note + e.stopPropagation(); e.preventDefault(); + openNoteThread(ev); + }, true); + return el; } 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 +12420,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); @@ -11940,6 +12438,7 @@ export function messagesFeature(ctx) { if (missing.length) warmTileThumbs(missing).then(() => queueTileThumbs(missing.filter((u) => !tileThumbs.has(u)))); } return h('div', { class: 'media-grid', 'data-key': 'media-grid:' + mediaMode }, + mediaMode === 'videos' ? h('div', { class: 'media-fit', 'data-key': 'media-fit' }, clipsToggle('media-fit-btn')) : null, ...items.map((it) => { const k = mediaKey(it); // a video tile shows a still at once: kept, given, or the server's @@ -12031,13 +12530,13 @@ export function messagesFeature(ctx) { // `fromFeed`: opened from a picture or clip in the feed (its pictures or // clips, in the feed's order), rather than from the media grid; `at`: where // the clip it opened on was, so it carries on from there - function openMediaPager(kind, start, { fromFeed = false, at = 0 } = {}) { + function openMediaPager(kind, start, { fromFeed = false, profile = null, thread = null, at = 0 } = {}) { // the page's own players stop: the pager's is the one to hear for (const v of document.querySelectorAll('video')) { try { v.pause(); } catch {} } for (const f of document.querySelectorAll('.yt-embed[data-playing] iframe')) { try { f.contentWindow.postMessage(JSON.stringify({ event: 'command', func: 'pauseVideo', args: [] }), '*'); f.closest('.yt-embed')._pausedByUs = true; } catch {} } - ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, at }; + ui.mediaPager = { kind, start, feed: curFeedId, fromFeed, profile, thread, at }; render(); } const closeMediaPager = () => ctx.closeLayer('mediaPager'); @@ -12047,17 +12546,26 @@ export function messagesFeature(ctx) { // the feed on the post of the picture last looked at; otherwise (the // media grid's pager, or nothing moved) it simply closes. function dismissPager() { - if (pager && pager.fromFeed && !pagerOnStart() && pager.cur) closePagerTo(pager.cur._rowId); + if (pager && pagerSource() && !pagerOnStart() && pager.cur) closePagerTo(pager.cur._rowId); else closeMediaPager(); } function closePagerTo(rowId) { - const c = feedNow(); + const src = pagerSource(); closeMediaPager(); // the pop that closes it lands on a later turn: the glide waits for the feed let tries = 0; const go = () => { + if (src.thread) { + // a thread's post is drawn already (no window): just bring it on screen if it isn't + if (ui.mediaPager) { if (++tries < 90) requestAnimationFrame(go); return; } + if (!src.live()) return; + const el = document.querySelector('.thread-page [data-zap-post="' + CSS.escape(rowId) + '"]'); + const r = el && el.getBoundingClientRect(); + if (r && (r.top < 0 || r.bottom > window.innerHeight)) window.scrollTo(0, Math.max(0, window.scrollY + r.top - 8)); + return; + } if (ui.mediaPager || !document.querySelector('.notes-feed')) { if (++tries < 90) requestAnimationFrame(go); return; } - landOnPost(c, rowId); + landOnPost(src, rowId); }; requestAnimationFrame(go); } @@ -12068,12 +12576,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 +12616,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 +12644,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,11 +12657,40 @@ 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; + // A clip that will play by itself has no poster: every poster there is + // (the kept frame, a post's imeta picture, the server's) is 480px wide + // and taken at some moment of the clip, so it showed for a beat, soft + // and not quite the first frame, and the clip's own first frame + // replaced it — a flash on every flick (Adam, 2026-10-16: "if we're + // auto playing let's not show the thumbnail let's just start on the + // first frame of the video"). The slide is the pager's black until the + // player, whose opening is in the cache by then (headStart), has its + // first frame. (Giving the next clip's player its file ahead of time + // was tried: it pulled the whole file down.) On a connection to be + // careful with the poster stays: the clip may never load (a heavy one + // waits for a tap), and the still is what there is. const media = video ? 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' }); - const caption = mediaCaption(ev); + poster: frugalData() ? videoThumbs.get(url) || posterOf(ev, url) || (videoInfo.get(url) || {}).image || serverThumb(url) : undefined }) + : h('img', { class: 'mp-media', 'data-src': quick, 'data-full': url, alt: '', draggable: 'false', decoding: 'async', + 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, true); const stop = (fn) => (e) => { e.stopPropagation(); fn(e); }; const slide = h('div', { class: 'mp-slide', 'data-k': mediaKey(it) }, media, @@ -12154,12 +12698,14 @@ export function messagesFeature(ctx) { h('div', { class: 'mp-left' }, h('button', { class: 'mp-author', type: 'button', onClick: stop(() => { leavePager(); openProfile(ev.pubkey); }) }, 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), + caption ? captionNode(ev, 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 +12723,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; @@ -12239,7 +12785,11 @@ export function messagesFeature(ctx) { // post), each a row of its pictures or clips — its quotes' too — side by // side (left and right go within it). Its slides, in reading order: const pagerSlides = () => (pager ? [...pager.scroller.querySelectorAll('.mp-slide')] : []); - const pagerGroups = () => (pager ? [...pager.scroller.querySelectorAll(':scope > .mp-group')] : []); + // the posts in the carousel: those on offer (a clip's post waits out of it + // until it is ready to play, and a clip the wrong way round for the screen + // is kept out — see pagerReveal, pagerFit), or every one built (all) + const PAGER_ON = '.mp-group:not(.mp-wait):not(.mp-off)'; + const pagerGroups = (all = false) => (pager ? [...pager.scroller.querySelectorAll(':scope > ' + (all ? '.mp-group' : PAGER_ON))] : []); // the slide a post's row rests on const rowSlide = (g) => { const row = g && g._row; if (!row || !row.children.length) return null; return row.children[Math.min(row.children.length - 1, Math.round(row.scrollLeft / (row.clientWidth || 1)))]; }; // bring a slide to the screen: its post, and within it, the slide @@ -12253,19 +12803,39 @@ export function messagesFeature(ctx) { // along (the next in this post), and the post after next function pagerAhead(slide) { const out = []; - const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && !n.classList.contains('mp-group')) n = n.nextElementSibling; return n; }; + // (a post kept out for its shape is passed over: nothing to ready there) + const nextGroup = (g) => { let n = g && g.nextElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.nextElementSibling; return n; }; const g1 = nextGroup(slide.closest('.mp-group')), g2 = nextGroup(g1); const down = g1 && rowSlide(g1); if (down) out.push(down); if (slide.nextElementSibling) out.push(slide.nextElementSibling); const down2 = g2 && rowSlide(g2); if (down2) out.push(down2); + const prevGroup = (g) => { let n = g && g.previousElementSibling; while (n && (!n.classList.contains('mp-group') || n.classList.contains('mp-off'))) n = n.previousElementSibling; return n; }; + const up = prevGroup(slide.closest('.mp-group')), back = up && rowSlide(up); + if (back) out.push(back); // (the one before: ready again for the swipe back) return out; } function pagerAttach(slide) { 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 {} } @@ -12291,6 +12861,8 @@ export function messagesFeature(ctx) { slides.forEach((s, j) => { const k = ahead.indexOf(s); if (Math.abs(j - i) <= 2 || k >= 0) slidePoster(s); + // the slides around the reader (and the one landed on) hold their clip's first frame + if (eager && ((j > i && j - i <= 5) || (j < i && i - j <= 3) || k >= 0) && !(j === i && pager.at && slide.getAttribute('data-k') === pager.start)) slideFrame(s, k); if (j === i) return; const v = s.querySelector('video.mp-media'); if (eager && k >= 0) { @@ -12309,6 +12881,56 @@ export function messagesFeature(ctx) { }); for (const s of slides.slice(i + 2, i + 4)) probeVideo(s.querySelector('video.mp-media').getAttribute('data-src')); } + // A slide near the reader holds its clip's first frame as its poster, so + // a flick glides onto the very picture the player shows first and the + // player takes over with no visible change — and a slide left behind + // (its player let go) keeps it for the swipe back. Some slides came up + // black until the player had its frame: the frame drawn here needed the + // next clip's cached opening, a file led by its index and a host that + // allows the read, and only the next clip had one at all — not the one + // before, not a flight's landing (Adam, 2026-10-16: "i'm still seeing + // mostly black between some videos and then they suddenly appear when + // it starts playing"). The server's first frame (vmedia first.jpg: frame + // 0 at full size, any host, any layout, cached at the CDN) is the first + // choice, fetched and decoded aside and put on the slide when it is + // ready; the frame drawn here is the fallback for the next clip; a clip + // with neither keeps the still there is (a kept frame, the post's + // picture), soft but not black, replaced by a first frame when one comes. + function slideFrame(s, k) { + const v = s.querySelector('video.mp-media'); + if (!v || v._frame === 'first' || v._frameBusy) return; + const url = v.getAttribute('data-src'); + const put = (p, kind) => { + if (!p || !pager || !s.isConnected || v._frame === 'first') return false; + if (kind === 'soft' && (v._frame || pager.cur === s)) return false; // never a soft still under the clip on screen + v.setAttribute('poster', p); v._frame = kind; + pagerReveal(); + return true; + }; + const soft = () => put(videoThumbs.get(url) || posterOf(s._ev, url), 'soft'); + const first = serverFirst(url); + if (first && !serverFirstFailed.has(url)) { + v._frameBusy = true; + const im = new Image(); + im.decoding = 'async'; + const ok = () => { + v._frameBusy = false; + if (im.naturalWidth && im.naturalHeight && !clipShape(url)) { rememberVideo(url, im.naturalWidth, im.naturalHeight); pagerFit(); } // its shape, from the frame + put(first, 'first'); + }; + im.onload = () => (im.decode ? im.decode().then(ok, ok) : ok()); + im.onerror = () => { v._frameBusy = false; serverFirstFailed.add(url); slideFrame(s, k); }; + im.src = first; + return; + } + if (k === 0 && !v._grabbed) { // the next clip: its opening is in the cache, the frame comes from there + v._grabbed = true; + v._frameBusy = true; + pagerPoster(playSrc(url)).then((p) => { v._frameBusy = false; if (!put(p, 'first')) soft(); }); + return; + } + if (first) soft(); // (the server had none: the still, if any, rather than black) + } // The opening of a clip about to be reached, fetched into the browser's // HTTP cache with a range request: the player's own request for the file // is answered from there as far as it goes, and streams on from the host. @@ -12341,14 +12963,14 @@ export function messagesFeature(ctx) { if (d.size && d.size <= 3e6 && d.size <= 2 * n) n = d.size; return Math.round(Math.min(Math.max(n, HEAD_MIN), HEAD_MAX, d.size || Infinity)); } - const headStarted = new Set(); + const headStarted = new Map(); // url -> its promise + const headDone = new Set(); // head starts finished (or given up: a host that won't allow the read) function headStart(url, share = 1) { - if (!url || url.startsWith('blob:') || headStarted.has(url) || frugalData()) return; - headStarted.add(url); - if (headStarted.size > 300) headStarted.delete(headStarted.values().next().value); + if (!url || url.startsWith('blob:') || frugalData()) return Promise.resolve(); + if (headStarted.has(url)) return headStarted.get(url); const want = Math.max(HEAD_MIN, Math.round(headStartBytes(url) * share)); const t0 = performance.now(); - fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { + const task = fetch(url, { headers: { Range: 'bytes=0-' + (want - 1) } }).then(async (r) => { const reader = r.ok && r.body ? r.body.getReader() : null; if (!reader) return; let got = 0; @@ -12360,7 +12982,10 @@ export function messagesFeature(ctx) { } const secs = (performance.now() - t0) / 1000; if (got >= 128 * 1024 && secs > 0) { const rate = got / secs; netRate = netRate ? netRate * 0.6 + rate * 0.4 : rate; } - }).catch(() => {}); + }).catch(() => {}).finally(() => { headDone.add(url); pagerReveal(); }); + headStarted.set(url, task); + if (headStarted.size > 300) { const k = headStarted.keys().next().value; headStarted.delete(k); headDone.delete(k); } + return task; } function playSlide(slide) { const v = slide.querySelector('video.mp-media'); @@ -12375,11 +13000,11 @@ export function messagesFeature(ctx) { // first frame itself (only the start of the file, when it leads with // its index or is served in ranges) — again on every return, since // a slide left behind lets go of its file - slidePoster(slide); + slidePoster(slide, true); if (videoThumbs.has(url)) pagerRelease(slide); else grabFrame(url).then((kept) => { if (!pager || !slide.isConnected) return; - if (kept) { slidePoster(slide); return; } + if (kept) { slidePoster(slide, true); return; } // no frame to keep (the host's bytes can't be read, or the index // trails): the player shows its own first frame under the cover const d = videoInfo.get(url) || {}; @@ -12401,8 +13026,10 @@ export function messagesFeature(ctx) { }); } // the kept frame as the poster, looked up as the slide comes near (it may - // have been kept after the slide was built) - function slidePoster(slide) { + // have been kept after the slide was built) — only where a poster is + // wanted: a careful connection, or a heavy clip under its cover (force) + function slidePoster(slide, force = false) { + if (!force && !frugalData()) return; const v = slide.querySelector('video.mp-media'); const thumb = v && videoThumbs.get(v.getAttribute('data-src')); if (thumb && v.getAttribute('poster') !== thumb) v.setAttribute('poster', thumb); @@ -12444,13 +13071,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 +13099,164 @@ 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; + const land = pager.kind === 'videos' && !frugalData() && rowSlide(to); // the landing's first frame, fetched on the way + if (land) slideFrame(land, -1); + 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)); + video ? h('div', { class: 'mp-top-r' }, + clipsToggle('mp-fit', () => pagerFit()), + 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), + // 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 +13268,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 +13308,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 ? PAGER_ON : '.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 +13346,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 +13378,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 +13392,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; @@ -12621,26 +13400,43 @@ export function messagesFeature(ctx) { // pictures are cheap — a post's all at once, the ones along its row // too (out of sight sideways, they'd load only on arrival); clips // are pagerNeighbours' call - if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) pagerAttach(x); + if (pager.kind === 'images') for (const x of e.target.closest('.mp-group').querySelectorAll('.mp-slide')) { + pagerAttach(x); + const im = x.querySelector('img.mp-media'); // decoded before the flick lands: it glides in as a picture, not a blank + if (im && im.decode) im.decode().catch(() => {}); + } } - }, { root: scroller, rootMargin: '150% 0px' }); + }, { root: scroller, rootMargin: '250% 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,42 +13444,96 @@ 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); - // the clip on screen's seek bar, every frame (timeupdate comes a few times a second) + 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). While the pager is in motion — under a finger, in a + // glide or a flight, snapping back — no seek bar shows at all: the + // clip's slide is off its mark, and the bar rode along with it, a + // white line sliding up the screen (Adam, 2026-10-16: "i don't want to + // see the seek bar while i'm transitioning to the next video"). It + // fades back in once the clip is square on the screen. if (video) { const p = pager; - const frame = () => { if (pager !== p) return; p.cur?.querySelector('.mp-seek')?._paint?.(); p.raf = requestAnimationFrame(frame); }; + const frame = () => { + if (pager !== p) return; + const bar = p.cur?.querySelector('.mp-seek'); + if (bar) bar._paint(); + const r = p.cur && p.cur.getBoundingClientRect(); + p.el.classList.toggle('moving', !!r && (Math.abs(r.top) > 2 || Math.abs(r.left) > 2)); + if (p.later && p.later.length && !pagerMoving()) { const l = p.later; p.later = []; for (const [g, cls, on] of l) pagerToggleGroup(g, cls, on); } + p.raf = requestAnimationFrame(frame); + }; 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); + for (const b of el.querySelectorAll('.mp-fit')) b.textContent = clipsLabel(); + pagerFit(); + }; window.addEventListener('resize', pager.onResize); // new posts land in the feed while the pager is open; counts move - pager.tick = setInterval(() => { if (pager) { growPager(); pagerFeedAhead(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500); + pager.tick = setInterval(() => { if (pager) { growPager(); pagerLoadAhead(); if (el.classList.contains('show')) pagerCounts(pager.cur); } }, 1500); } - // A pager opened from the feed walks the feed's own posts, which page in - // ten at a time as the feed is scrolled. Swiping is the scrolling here: - // with only a few posts left below the one on screen (PAGER_AHEAD), the feed pages on - // (the posts it holds, then older ones from the relays) until there are - // more — several pages if those posts carry none. A page that brings - // nothing (the feed busy, or at its end) stops it; the tick tries again. - // how many posts ahead to keep: clips are fewer and further between - // (more pages to find the next) and each is watched a while, so further - const PAGER_AHEAD = { images: 4, videos: 6 }; - let pagerFeeding = false; - async function pagerFeedAhead() { - if (pagerFeeding || !pager || !pager.fromFeed) return; - pagerFeeding = true; + // All media pagers build the same buffer, retaining the source's ordering + // and filters. The furthest post reached never moves back when we wrap, + // so pending refills continue while the reader circles through older items. + const PAGER_INITIAL = 30, PAGER_AHEAD = 20; + function pagerLoadSource(p) { + const src = pagerSource(p); + if (src) return { + more: src.more, done: src.done, + progress: () => src.c.shown + ':' + src.c.notes.length, + }; + // A profile cache can be replaced while refreshing; never fall back + // to another source in that gap. The next timer tick will try again. + if (p.profile || p.fromFeed) return { more: async () => {}, done: () => true, progress: () => '' }; + const c = feedNow(), r = mediaIndex(c.id); + const curated = feedDef(c.id)?.curated; + return { + more: () => crawlMedia(c, p.kind), + done: () => c.stopped || (curated ? c.end && c.shown >= c.notes.length : !!r.end), + progress: () => [r.until, r[p.kind].size, c.shown, c.notes.length].join(':'), + }; + } + async function pagerLoadAhead() { + const p = pager; + if (!p) return; + const groups = pagerGroups(true); + 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 +13541,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); @@ -12703,19 +13552,66 @@ export function messagesFeature(ctx) { let g = pager.groups.get(gk); if (!g) { g = pagerGroup(); + if (pager.kind === 'videos' && !frugalData()) g.classList.add('mp-wait'); // out of the carousel until its clip is ready to play pager.groups.set(gk, g); - pager.scroller.insertBefore(g, pager.tail || null); - first ||= s; + pager.scroller.append(g); } + if (k === pager.start) { g.classList.remove('mp-wait'); g._opened = true; } // the clip tapped: in, whatever its state 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(); + pagerFit(); + pagerPosition(); + } + // A post's clip is ready to play when its first frame is on the slide and + // its opening is in the cache (the head start done): only then does the + // post join the carousel, so the reader never lands on a black slide + // (Adam, 2026-10-16: "i never want to land on a black screen i'd rather + // just not have a video in carousel yet if it's not ready to play"). A + // post joining above the one on screen would push it down: the scroll + // moves by the post's height in the same frame, snapping off meanwhile, + // and not while the pager is in motion (then, once it has settled). + function pagerReveal() { + if (!pager || pager.kind !== 'videos') return; + const eager = !frugalData(); + for (const g of pager.scroller.querySelectorAll(':scope > .mp-group.mp-wait')) { + const v = g._row.firstElementChild && g._row.firstElementChild.querySelector('video.mp-media'); + if (!v) continue; + const url = v.getAttribute('data-src'); + // the frame is asked of a clip that can have one (the server's: an https + // clip); a local http clip (a dev setup) waits only for its opening + if ((serverFirst(url) && !v._frame) || (eager && !headDone.has(playSrc(url)))) continue; + pagerToggleGroup(g, 'mp-wait', false); + } + } + // the posts whose clips are the wrong way round for the screen stay out + // (not the one opened on; a shape not yet known is let in) + function pagerFit() { + if (!pager || pager.kind !== 'videos') return; + const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); + for (const g of pagerGroups(true)) { + const fits = g._opened || g === cg || [...g._row.children].some((s) => { const v = s.querySelector('video.mp-media'); return !v || clipFits(v.getAttribute('data-src')) !== false; }); + pagerToggleGroup(g, 'mp-off', !fits); + } + } + function pagerToggleGroup(g, cls, on) { + if (!pager || g.classList.contains(cls) === on) return; + const cur = pager.cur || pager.anchor, cg = cur && cur.closest('.mp-group'); + const above = !!cg && cg !== g && !!(g.compareDocumentPosition(cg) & Node.DOCUMENT_POSITION_FOLLOWING); + if (above && pagerMoving()) { (pager.later ||= []).push([g, cls, on]); return; } // once it has settled (the frame loop) + const box = pager.scroller; + const shown = g.offsetParent !== null; // (a group hidden the other way stays hidden: no height to count) + const h0 = shown ? g.offsetHeight : 0; + box.style.scrollSnapType = 'none'; + g.classList.toggle(cls, on); + if (above) box.scrollTop += (g.offsetParent !== null ? g.offsetHeight : 0) - h0; + requestAnimationFrame(() => { if (pager && !box._glide) box.style.scrollSnapType = ''; }); + pagerPosition(); + if (cg === g && on) { const s = pagerHere(); if (s && s !== pager.cur) pagerArrive(s); } // the one on screen went out (turned the phone): whoever is there now } + const pagerMoving = () => !!pager && (pager.flying || !!pager.scroller._glide || pager.el.classList.contains('moving')); // A post in the pager: its row of slides, side by side, and — with more // than one — dots saying how many and which. function pagerGroup() { @@ -12732,39 +13628,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 +13657,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 +13685,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 +13755,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 +13764,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 +13834,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 +13875,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(); @@ -14553,6 +15425,28 @@ export function messagesFeature(ctx) { let notif = null; // { status, items } const NOTIF_V = 2; let notifAt = 0, notifUnsub = null; + // Every notification event seen, id and time, for reconciliation (NIP-77) + // with the relays that speak it: a refresh then fetches only what's new, + // not the newest hundred and twenty again — a hundred kilobytes a visit. + // All of them, not the rows kept: a zap receipt folded into its twin, or + // a hidden author's reply, would otherwise come back on every refresh. + const NOTIF_SEEN_MAX = 300; // the relay reconciles its newest 120 + let notifHeld = null; // id -> created_at, oldest first + function notifHeldItems() { + if (!notifHeld) { + notifHeld = new Map(); + try { const s = st(); if (s.notifHeldPk === identityPk()) for (const [id, ts] of s.notifHeld || []) notifHeld.set(id, ts | 0); } catch {} + for (const x of notifNow().items) if (x && x.id && x.ts && !notifHeld.has(x.id)) notifHeld.set(x.id, x.ts); + } + return [...notifHeld].map(([id, ts]) => [ts, id]); + } + function noteNotifHeld(evs) { + notifHeldItems(); + let grew = false; + for (const ev of evs || []) if (ev && ev.id && !notifHeld.has(ev.id)) { notifHeld.set(ev.id, ev.created_at | 0); grew = true; } + while (notifHeld.size > NOTIF_SEEN_MAX) notifHeld.delete(notifHeld.keys().next().value); + return grew; + } const notifNoteAsking = new Set(); const notifSeen = () => st().notifSeen || 0; // Notifications are about the IDENTITY, not the wallet: the stored list @@ -14612,6 +15506,7 @@ export function messagesFeature(ctx) { for (const id of wanted) if (notifNotes.has(id)) notes[id] = savedNotifNote(notifNotes.get(id)); const s2 = st(); s2.notifs = notif.items; s2.notifsPk = identityPk(); s2.notifsV = NOTIF_V; + if (notifHeld) { s2.notifHeld = [...notifHeld]; s2.notifHeldPk = identityPk(); } s2.notifNoteCache = notes; s2.notifNotesPk = identityPk(); save(s2); } @@ -14724,9 +15619,11 @@ export function messagesFeature(ctx) { notifAt = Date.now(); const g = session; try { - const evs = await queryOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, 5000); + const evs = await syncOn(zapRelays(), { kinds: NOTIF_KINDS, '#p': my, limit: 120 }, notifHeldItems(), { maxWait: 5000 }); if (g !== session) return; // asked for the previous account + const grew = noteNotifHeld(evs); if (mergeNotifs(evs)) scheduleRepaint(); + if (grew) persistNotifs(); } catch {} finally { if (g === session) { if (notif) notif.status = 'ready'; @@ -14927,7 +15824,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 @@ -15278,7 +16175,7 @@ export function messagesFeature(ctx) { noteCountsReady.clear(); // what happened to THEIR posts stays with them — the next identity // starts its list empty and asks the relays under its own key - stopNotifWatch(); notif = null; notifAt = 0; notifNotes.clear(); + stopNotifWatch(); notif = null; notifHeld = null; notifAt = 0; notifNotes.clear(); session++; zapWho.clear(); whoOpenIds.clear(); clearTimeout(zapSaveT); zapSaveT = null; zapSeed = null; |
