diff options
| author | Adam Soltys <[email protected]> | 2026-09-23 22:51:58 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-23 22:51:58 -0700 |
| commit | 75679fdd5d24d36e3c54316e88ccefcf954afc82 (patch) | |
| tree | c86a369455da3c2e9532d51d2e72898afda8fccc /src/thread-cache.js | |
| parent | fb170b4783c9b49c73e926c08aba9bc4d1bb418e (diff) | |
| download | coinosv3-75679fdd5d24d36e3c54316e88ccefcf954afc82.tar.gz coinosv3-75679fdd5d24d36e3c54316e88ccefcf954afc82.zip | |
Feed: threads cached locally, posts never inserted under the reader, rows keep their first look.
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 <[email protected]>
Diffstat (limited to 'src/thread-cache.js')
| -rw-r--r-- | src/thread-cache.js | 59 |
1 files changed, 59 insertions, 0 deletions
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 + }, + }; +} |
