aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js33
-rw-r--r--src/thread-prefetch.js53
-rw-r--r--tools/thread-cache-test.js18
-rw-r--r--tools/thread-prefetch-test.js31
4 files changed, 129 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 = [];
+ },
+ };
+}
diff --git a/tools/thread-cache-test.js b/tools/thread-cache-test.js
index 6bac353..67b191b 100644
--- a/tools/thread-cache-test.js
+++ b/tools/thread-cache-test.js
@@ -139,6 +139,24 @@ try {
assert.equal(restored.root.id, r.id);
assert.equal(restored.replies.length, 2);
});
+ for (const clickWhileLoading of [false, true]) {
+ const warm = makeFeature(), seed = note(clickWhileLoading ? 501 : 500);
+ const answer = note(clickWhileLoading ? 511 : 510, [['e', seed.id, '', 'root']]);
+ requests = [];
+ const prefetched = warm.feature.testThread(seed, true);
+ await wait(() => waiting.length === 1);
+ if (clickWhileLoading) assert.equal(warm.feature.testThread(seed), prefetched);
+ for (const q of waiting.splice(0)) q.resolve([answer]);
+ await prefetched.loading;
+ if (!clickWhileLoading) assert.equal(store.find(seed.id), null, 'speculation does not evict persisted visits');
+ const opened = warm.feature.testThread(seed);
+ assert.equal(opened, prefetched);
+ assert.equal(opened.status, 'ready');
+ assert.equal(opened.replies[0].id, answer.id);
+ assert.equal(requests.length, 1, 'click shares the existing relay query');
+ assert.equal(store.find(seed.id).replies[0].id, answer.id);
+ }
+ console.log('✓ clicks reuse completed and in-flight prefetches and persist visited threads');
const deepLink = makeFeature();
requests = [];
await deepLink.feature.testOpen({ id: e.id });
diff --git a/tools/thread-prefetch-test.js b/tools/thread-prefetch-test.js
new file mode 100644
index 0000000..6329e64
--- /dev/null
+++ b/tools/thread-prefetch-test.js
@@ -0,0 +1,31 @@
+import assert from 'node:assert/strict';
+import { createThreadPrefetch } from '../src/thread-prefetch.js';
+let time = 0, task, calls = [], rows = [], resolve;
+const cached = new Set();
+const doc = { hidden: false, documentElement: { clientHeight: 800 }, querySelectorAll: () => rows };
+const nav = { onLine: true, connection: {} };
+const p = createThreadPrefetch({ doc, nav, now: () => time,
+ schedule: (fn) => { task = fn; return 1; }, cancel: () => { task = null; },
+ key: (e) => e.id, cached: (e) => cached.has(e.id),
+ load: (e) => { calls.push(e.id); cached.add(e.id); return new Promise((r) => { resolve = r; }); },
+});
+const row = (id, top = 0, height = 300) => ({ id, top, height,
+ getAttribute() { return this.id; },
+ getBoundingClientRect() { return { top: this.top, bottom: this.top + this.height, height: this.height }; },
+});
+const tick = async (ms = 250) => { time += ms; const fn = task; task = null; fn?.(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); };
+const watch = (...rs) => { rows = rs; for (const r of rs) p.watch({ id: r.id }); };
+const dwell = async () => { for (let i = 0; i < 7; i++) await tick(); };
+watch(row('passing')); await tick(); rows = []; await dwell(); assert.deepEqual(calls, []);
+watch(row('moving')); for (let i = 0; i < 8; i++) { rows[0].top += 30; await tick(); } assert.deepEqual(calls, []);
+watch(row('hidden')); doc.hidden = true; await dwell(); assert.deepEqual(calls, []); doc.hidden = false;
+nav.connection.saveData = true; await dwell(); assert.deepEqual(calls, []); nav.connection.saveData = false;
+watch(row('tall', -200, 1800), row('second', 100)); await dwell(); assert.deepEqual(calls, ['tall']);
+await dwell(); assert.deepEqual(calls, ['tall'], 'only one in-flight fetch');
+resolve(); await tick(); await tick(); assert.deepEqual(calls, ['tall', 'second']);
+resolve(); await tick(); await dwell(); assert.equal(calls.length, 2, 'cached posts are reused');
+for (let i = 0; i < 5; i++) { watch(row('rate' + i)); await dwell(); resolve(); await tick(); }
+assert.equal(calls.length, 6, 'six starts per minute');
+await tick(60000); assert.equal(calls.length, 7); resolve(); await tick();
+watch(row('cancelled')); p.stop(); await dwell(); assert.equal(calls.length, 7);
+console.log('✓ dwell, fast scrolling, unmount, hidden tab, data saver, tall posts, concurrency, cache, rate limit and stop');