diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 33 | ||||
| -rw-r--r-- | src/thread-prefetch.js | 53 |
2 files changed, 80 insertions, 6 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 53f6c99..759b155 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -32,6 +32,7 @@ import { saveInbox } from '../dm-inbox.js'; import { loadMediaIndex, saveMediaIndex, wipeMediaIndex } from '../media-cache.js'; import { mergeFeedWindow } from '../feed-window.js'; import { createThreadStore } from '../thread-cache.js'; +import { createThreadPrefetch } from '../thread-prefetch.js'; import { createFeedCache, FEED_CACHE_POSTS, FEED_SEED_POSTS } from '../feed-cache.js'; import { POPULAR_RELAYS, FIREHOSE_RELAYS, popularCandidates } from '../popular-feed.js'; import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl, primalCache } from '../recipient-search.js'; @@ -8841,10 +8842,12 @@ export function messagesFeature(ctx) { const flagged = !pending && !(ui.revealed && ui.revealed.has(ev.id)) ? reportsOn(ev) : null; if (flagged) return foldedRow(ev, flagged); const openable = open && !pending; + if (openable) threadPrefetch.watch(ev); if (!pending) watchZaps([ev.id]); return h('div', { class: 'row note-post', 'data-zap-post': ev.id, + 'data-thread-prefetch': openable ? ev.id : undefined, 'data-focus-note': focus ? '1' : undefined, style: 'padding:10px 0' + (openable ? ';cursor:pointer' : '') @@ -8888,6 +8891,18 @@ export function messagesFeature(ctx) { // ---- thread view: a note in its conversation ---------------------------- const threadCache = new Map(); // root id -> { status, root, replies } const threadStore = createThreadStore(); + const threadPrefetch = createThreadPrefetch({ + key: rootIdOf, + cached: (ev) => threadCache.has(rootIdOf(ev)), + load: (ev) => threadFor(ev, true).loading, + }); + function trimPrefetchedThreads() { + const idle = [...new Set(threadCache.values())].filter((c) => c.prefetched + && c.status === 'ready' && ui.noteThread?.rootId !== c.rootId); + for (const c of idle.slice(0, Math.max(0, idle.length - 12))) { + for (const [id, entry] of threadCache) if (entry === c) threadCache.delete(id); + } + } const persistThread = (c) => { if (!c.root) return; const faces = {}; @@ -8959,13 +8974,18 @@ export function messagesFeature(ctx) { const marked = es.find((x) => x[3] === 'root'); return (marked || es[0] || [])[1] || ev.id; } - function threadFor(seed) { + function threadFor(seed, prefetch = false) { const requestedRootId = rootIdOf(seed); let c = threadCache.get(requestedRootId) || [...new Set(threadCache.values())] .find((c) => c.replies.some((e) => e.id === seed.id || e.id === requestedRootId)); if (c) { threadCache.set(requestedRootId, c); - c.focusId = seed.id; + if (!prefetch) { + c.focusId = seed.id; + const wasPrefetched = c.prefetched; + c.prefetched = false; + if (wasPrefetched && c.status === 'ready') persistThread(c); + } if (seed.id === c.rootId) c.root ||= seed; else if (!c.replies.some((ev) => ev.id === seed.id)) { c.replies = [...c.replies, seed].sort((a, b) => a.created_at - b.created_at); @@ -8992,14 +9012,14 @@ 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, + 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 || {} }; 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); threadCache.set(requestedRootId, c); threadCache.set(rootId, c); if (ui.noteThread?.focusId === seed.id) ui.noteThread.rootId = rootId; - (async () => { + c.loading = (async () => { // Where a thread lives: the reply's author, the people it tags (the // root's author is usually first among them), the relay hints on its // e tags, and ours. The reply author's relays alone missed roots @@ -9052,7 +9072,7 @@ export function messagesFeature(ctx) { .filter((e) => e && e.id !== c.rootId && !seen.has(e.id) && seen.add(e.id)) .sort((a, b) => a.created_at - b.created_at); c.status = 'ready'; - persistThread(c); + if (!c.prefetched) persistThread(c); if (ui.noteThread && ui.noteThread.rootId === c.rootId) { renderThreadStable(); // The inline reply box may have MOVED on this render (it slots under @@ -9068,7 +9088,7 @@ export function messagesFeature(ctx) { })().catch(() => { c.status = 'ready'; if (ui.noteThread && ui.noteThread.rootId === c.rootId) renderThreadStable(); - }); + }).finally(trimPrefetchedThreads); return c; } function openNoteThread(ev) { @@ -13250,6 +13270,7 @@ export function messagesFeature(ctx) { clearTimeout(feedAheadTimer); feedAheadTimer = null; for (const c of feedStates.values()) c.stopped = true; feedStates.clear(); + threadPrefetch.stop(); threadCache.clear(); follows = null; followsAt = 0; feed = null; relayLists = null; mutes = null; mutesAt = 0; diff --git a/src/thread-prefetch.js b/src/thread-prefetch.js new file mode 100644 index 0000000..6d34db6 --- /dev/null +++ b/src/thread-prefetch.js @@ -0,0 +1,53 @@ +// Dwell is measured on mounted rows, including posts taller than the viewport. +// Sampling also catches fast movement within a large post (IO alone cannot). +export function createThreadPrefetch({ load, key, cached, doc = globalThis.document, + nav = globalThis.navigator, now = Date.now, schedule = setTimeout, cancel = clearTimeout }) { + const seeds = new Map(), dwell = new Map(); + let timer = null, busy = false, starts = [], generation = 0; + const allowed = () => !doc.hidden && nav?.onLine !== false && !nav?.connection?.saveData + && !['slow-2g', '2g'].includes(nav?.connection?.effectiveType); + function tick() { + timer = null; + const time = now(), visible = new Set(), mounted = new Set(), ready = []; + const height = doc.documentElement.clientHeight; + for (const row of doc.querySelectorAll('[data-thread-prefetch]')) { + const id = row.getAttribute('data-thread-prefetch'), seed = seeds.get(id); + mounted.add(id); + if (!seed || !allowed() || cached(seed)) continue; + const r = row.getBoundingClientRect(); + const overlap = Math.min(r.bottom, height) - Math.max(r.top, 0); + if (overlap < Math.min(r.height, height) * 0.5 || overlap <= 0) continue; + visible.add(id); + let d = dwell.get(id); + // More than 100px/s is still scrolling past; slow reading can qualify. + if (!d || Math.abs(r.top - d.top) > 25) d = { since: time, top: r.top }; + d.top = r.top; + dwell.set(id, d); + if (time - d.since >= 1200) ready.push(seed); + } + for (const id of seeds.keys()) if (!mounted.has(id)) seeds.delete(id); + for (const id of dwell.keys()) if (!visible.has(id)) dwell.delete(id); + starts = starts.filter((t) => time - t < 60000); + if (!busy && starts.length < 6 && ready.length) { + const seed = ready[0], token = generation; + busy = true; starts.push(time); + // The loader inserts synchronously, so clicks share its in-flight work. + Promise.resolve().then(() => token === generation && load(seed)).catch(() => {}).finally(() => { + if (token === generation) busy = false; + }); + for (const [id, ev] of seeds) if (key(ev) === key(seed)) dwell.delete(id); + } + if (seeds.size) timer = schedule(tick, 250); + } + return { + watch(seed) { + if (!doc) return; + seeds.set(seed.id, seed); + if (timer === null) timer = schedule(tick, 250); + }, + stop() { + generation++; cancel(timer); timer = null; busy = false; + seeds.clear(); dwell.clear(); starts = []; + }, + }; +} |
