From 75679fdd5d24d36e3c54316e88ccefcf954afc82 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Wed, 23 Sep 2026 22:51:58 -0700 Subject: Feed: threads cached locally, posts never inserted under the reader, rows keep their first look. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work by Codex, wrapped up and verified here. Threads: a viewed conversation is persisted (thread-cache.js, up to 12 threads / 160 events), so a refresh or a cached note link shows the root and ancestors at once instead of the selected reply and a spinner; relays are re-asked in the background and an empty answer keeps the cache. Feed window (feed-window.js): arrivals are merged without inserting between visible rows — a gap fill under the reader's eyes waits until that part of the feed is offscreen, prepends anchor the reading position, the notice counts what went in above, and only its tap goes to the top. Older pages are prepared a few screens ahead; a page's pictures and faces are decoded before it is shown. Rows keep the presentation they were admitted with (feedPaint): background profile and quote refreshes no longer rewrite text or swap faces mid-read; media dimensions are known before paint. Thumbnails queue instead of being dropped past the first three. Tests: thread-cache, feed-window, feed-stability; feed-live updated for the new arrival rules (the notice counts up while you read). Co-Authored-By: Claude Fable 5.1 --- src/thread-cache.js | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/thread-cache.js (limited to 'src/thread-cache.js') diff --git a/src/thread-cache.js b/src/thread-cache.js new file mode 100644 index 0000000..6f27332 --- /dev/null +++ b/src/thread-cache.js @@ -0,0 +1,59 @@ +// Public kind-1 thread context, available before wallet unlock as well as +// inside a wallet. Keep complete event text/tags; truncating a cached event +// would make the content change when a relay answered after the first paint. +const KEY = 'btc-wallet-note-threads'; +const MAX_THREADS = 12; +const MAX_EVENTS = 160; +const MAX_BYTES = 1_000_000; // JSON code units; at most about 2 MB in storage +const validNote = (e) => e?.kind === 1 && typeof e.id === 'string' + && /^[0-9a-f]{64}$/.test(e.id) && typeof e.pubkey === 'string' + && /^[0-9a-f]{64}$/.test(e.pubkey) && typeof e.content === 'string' + && Number.isFinite(e.created_at) && Array.isArray(e.tags) + && e.tags.every((t) => Array.isArray(t) && t.every((v) => typeof v === 'string')); +const savedNote = ({ id, pubkey, kind, created_at, content, tags }) => ({ id, pubkey, kind, created_at, content, tags }); + +export function createThreadStore(storage) { + if (storage === undefined) { try { storage = globalThis.localStorage; } catch {} } + const read = () => { + try { + const raw = storage?.getItem(KEY); + if (!raw || raw.length > MAX_BYTES) return []; + const data = JSON.parse(raw); + if (!Array.isArray(data)) return []; + return data.filter((c) => validNote(c?.root) && c.rootId === c.root.id + && Array.isArray(c.replies) && c.replies.length < MAX_EVENTS + && c.replies.every(validNote)).slice(0, MAX_THREADS); + } catch { return []; } + }; + return { + find(id) { + return read().find((c) => c.rootId === id || c.replies.some((e) => e.id === id)) || null; + }, + save(thread, focusId) { + if (!validNote(thread.root)) return; + const events = new Map([thread.root, ...thread.replies.filter(validNote)].map((e) => [e.id, e])); + const keep = new Map([[thread.root.id, thread.root]]); + // The selected reply and every locally known ancestor get priority + // over sibling replies when a large conversation needs trimming. + const visit = (id) => { + if (keep.has(id) || keep.size >= MAX_EVENTS) return; + const e = events.get(id); + if (!e) return; + keep.set(id, e); + for (const t of e.tags) if (t[0] === 'e' && t[3] !== 'mention') visit(t[1]); + }; + visit(focusId); + for (const e of events.values()) { + if (keep.size >= MAX_EVENTS) break; + visit(e.id); + } + const entry = { rootId: thread.root.id, root: savedNote(thread.root), + replies: [...keep.values()].filter((e) => e.id !== thread.root.id).map(savedNote) }; + const entries = [entry, ...read().filter((c) => c.rootId !== entry.rootId)].slice(0, MAX_THREADS); + let json = JSON.stringify(entries); + while (json.length > MAX_BYTES && entries.length > 1) { entries.pop(); json = JSON.stringify(entries); } + if (json.length > MAX_BYTES) return; // keep the previous cache if this thread alone is too large + try { storage?.setItem(KEY, json); } catch {} // full/disabled storage must not break reading + }, + }; +} -- cgit v1.2.3