aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/messages.js94
-rw-r--r--src/feed-cache.js53
-rw-r--r--tools/feed-cache-test.js25
-rw-r--r--tools/feed-stability-test.js30
4 files changed, 173 insertions, 29 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index a73809e..7af2259 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -31,6 +31,7 @@ import {
import { saveInbox } from '../dm-inbox.js';
import { mergeFeedWindow } from '../feed-window.js';
import { createThreadStore } from '../thread-cache.js';
+import { createFeedCache, FEED_CACHE_POSTS } from '../feed-cache.js';
import { makeSearcher, resultRows, fallbackAvatar, warmSearch, punkImageUrl, punkSmallUrl } from '../recipient-search.js';
import { getNetwork } from '../api.js';
import { decodeBolt11 } from '../ark/lightning.js';
@@ -3726,10 +3727,10 @@ export function messagesFeature(ctx) {
// Cached events avoid waiting for relays; their presentation is prepared
// before the reading surface is shown.
const FEED_CACHE = 'feedNotes';
- const FEED_LIMIT = 80;
- const FEED_PAGE = 20; // posts on screen at once, grown as you scroll
- const FEED_KEEP = 200; // in memory
- const FEED_STORE = 50; // ...and on disk
+ const FEED_LIMIT = 30;
+ const FEED_PAGE = 10; // posts on screen at once, grown as you scroll
+ const FEED_KEEP = 80; // memory target; retain rows already being read
+ const FEED_STORE = FEED_CACHE_POSTS; // ...and on disk
let feed = null; // the feed on screen: { id, status, notes, shown, end, loadingMore, at }
let feedUnsubs = [];
// ---- many feeds ----------------------------------------------------------
@@ -3856,9 +3857,16 @@ export function messagesFeature(ctx) {
const feedTopics = (def = feedDef()) => [...new Set(((def && def.topics) || []).map(normTopic).filter(Boolean))].slice(0, 20);
const feedHasQuery = (def = feedDef()) => !!def && (feedAuthors(def).length > 0 || feedTopics(def).length > 0);
const feedCacheKey = (id) => (id === FOLLOWING ? FEED_CACHE : FEED_CACHE + ':' + id);
+ const feedDisk = () => typeof wallet.featureStateKey === 'function'
+ ? createFeedCache(localStorage, wallet.featureStateKey(FEED_CACHE)) : null;
const saveFeedCache = (c) => {
if (adhocFeeds.has(c.id)) return; // a session feed leaves nothing behind
- try { wallet.saveFeatureState(feedCacheKey(c.id), c.notes.slice(0, FEED_STORE).map(slimNote)); } catch {}
+ try {
+ const notes = c.notes.slice(0, FEED_STORE);
+ const disk = feedDisk();
+ if (disk) disk.save(c.id, notes);
+ else wallet.saveFeatureState(feedCacheKey(c.id), notes);
+ } catch {}
};
// posts on a topic come from the big public relays as well as ours: a
// hashtag has no author whose outbox we could read
@@ -4088,21 +4096,51 @@ export function messagesFeature(ctx) {
let c = feedStates.get(curFeedId);
if (!c) {
let stored = [];
- try { stored = wallet.loadFeatureState(feedCacheKey(curFeedId), []) || []; } catch {}
+ try { stored = feedDisk()?.read(curFeedId) || wallet.loadFeatureState(feedCacheKey(curFeedId), []) || []; } catch {}
+ if (!Array.isArray(stored)) stored = [];
+ 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);
+ while (feedStates.size > 4) {
+ const oldest = feedStates.keys().next().value;
+ feedStates.get(oldest).stopped = true;
+ feedStates.delete(oldest);
+ }
feed = c;
- // Cached events still need their pictures decoded. Prepare two pages
- // before exposing the first, including the page immediately below it.
- c.boot = notesReady(stored.slice(0, FEED_PAGE * 2)).finally(() => {
+ // 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.booting = false;
- scheduleRepaint();
+ 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));
});
- c.boot.then(() => refreshFeed({}, c));
+ prefetchFeed(c);
}
feed = c;
return c;
}
+ let feedWarmTimer = null, feedWarmSession = 0;
+ async function prefetchFeed(c) {
+ const def = feedDef(c.id);
+ const authors = feedAuthors(def).slice(0, REQ_AUTHORS), topics = feedTopics(def);
+ if (!authors.length && !topics.length) { c.status = 'ready'; return; }
+ c.at = Date.now();
+ try {
+ const events = await queryOn(NOTE_RELAYS, { kinds: [1], limit: FEED_PAGE,
+ ...(authors.length ? { authors } : {}), ...(topics.length ? { '#t': topics } : {}) }, 3000);
+ if (c.stopped) return;
+ await mergeFeed(events.filter((e) => e.kind === 1 && !isReply(e) && !hidden(e))
+ .sort((a, b) => b.created_at - a.created_at).slice(0, FEED_PAGE), {}, c);
+ } catch {} finally {
+ if (!c.stopped) {
+ c.status = 'ready';
+ if (c === feed && ui.chatOpen && ui.msgView === 'feed') { scheduleRepaint(); watchFeed(); }
+ }
+ }
+ }
function switchFeed(id) {
if (!feedDef(id)) return;
if (id !== curFeedId) {
@@ -4113,14 +4151,8 @@ export function messagesFeature(ctx) {
const c = feedNow();
c.unseen = 0; c.shown = FEED_PAGE;
admitFeed(c.deferred || [], c, true);
- if (!c.booting) {
- c.presentations.clear();
- c.booting = true;
- c.boot = notesReady(c.notes.slice(0, FEED_PAGE * 2)).finally(() => {
- c.booting = false;
- scheduleRepaint();
- });
- }
+ // An already warmed feed can paint synchronously on entry.
+ if (!c.booting) c.presentations.clear();
watchFeed();
c.boot.then(() => refreshFeed({}, c));
try { window.scrollTo({ top: 0 }); } catch {}
@@ -4256,7 +4288,9 @@ export function messagesFeature(ctx) {
&& !known.has(e.id) && !staged.has(e.id) && known.add(e.id) && staged.add(e.id));
if (!add.length) return false;
if (c.booting) await c.boot;
+ if (c.stopped) return false;
await notesReady(add);
+ if (c.stopped) return false;
for (const e of add) staged.delete(e.id);
// A catch-up is settled once, after every relay has answered (see
// settleCatchup) — not chunk by chunk, which is what made the pill count
@@ -4282,7 +4316,7 @@ export function messagesFeature(ctx) {
});
c.deferred = result.deferred;
if (!result.added.length) return;
- c.shown = result.shown;
+ c.shown = c.opened ? result.shown : FEED_PAGE;
c.notes = result.notes;
const retained = new Set(c.notes.map((e) => e.id));
for (const id of c.presentations.keys()) if (!retained.has(id)) c.presentations.delete(id);
@@ -6720,6 +6754,7 @@ export function messagesFeature(ctx) {
syncFollowSets().catch(() => {}); // throttled inside
syncReports().catch(() => {}); // likewise
const c = feedNow();
+ c.opened = true;
const def = feedDef();
const authors = feedAuthors(def);
const hasQuery = feedHasQuery(def);
@@ -6774,9 +6809,9 @@ export function messagesFeature(ctx) {
: !visible.length
? h('div', { class: 'small faint', style: 'text-align:center;padding:12px 0' }, t('feedEmpty'))
: h('div', { class: 'card col notes-feed', style: 'gap:0' }, ...rows),
- c.loadingMore
- ? h('div', { class: 'row gap6', style: 'justify-content:center;padding:4px 0' }, h('span', { class: 'spinner sm' }))
- : null,
+ // Older posts prepare offscreen; a background fetch should not add
+ // a spinner to an already readable feed.
+
...noteOverlays());
}
@@ -8247,6 +8282,7 @@ export function messagesFeature(ctx) {
// nobody yet — an established nostr user keeps their own list.
identitySignedInNew() { setTimeout(() => seedNewIdentity({ onlyIfNoFollows: true }), 0); return true; },
init() {
+ const session = ++feedWarmSession;
// this wallet's cached faces, from its own namespace — the keys are
// in place now, whatever the header asked for before
profilesWarmed = false;
@@ -8292,7 +8328,13 @@ export function messagesFeature(ctx) {
for (const jm of communities()) { try { ensureRoom(jm, { subscribe: false }); } catch {} }
syncLists().catch(() => {});
syncInbox().catch(() => {});
- syncFollows().catch(() => {}); // the Follow button should be right on first paint
+ // Warm a small opening page while the wallet is idle, before Feed is
+ // opened. This is one bounded query, not a live feed subscription.
+ syncFollows().catch(() => {});
+ feedWarmTimer = setTimeout(() => {
+ feedWarmTimer = null;
+ if (session === feedWarmSession) feedNow();
+ }, 1500);
syncMutes().catch(() => {}); // and a muted author shouldn't flash past before the list lands
registerPush().catch(() => {}); // silent refresh when permission already granted
},
@@ -8310,6 +8352,10 @@ export function messagesFeature(ctx) {
if (zapLiveUnsub) { try { zapLiveUnsub(); } catch {} zapLiveUnsub = null; }
clearTimeout(zapTimer); zapTimer = null; zapQueue = new Set(); zapRecent = [];
stopFeedWatch();
+ ++feedWarmSession;
+ clearTimeout(feedWarmTimer); feedWarmTimer = null;
+ clearTimeout(feedAheadTimer); feedAheadTimer = null;
+ for (const c of feedStates.values()) c.stopped = true;
feedStates.clear();
threadCache.clear();
follows = null; followsAt = 0; feed = null; feedAt = 0; relayLists = null;
diff --git a/src/feed-cache.js b/src/feed-cache.js
new file mode 100644
index 0000000..ea562f7
--- /dev/null
+++ b/src/feed-cache.js
@@ -0,0 +1,53 @@
+// Disposable public feed cache. Budget is conservative UTF-16 storage bytes.
+export const FEED_CACHE_POSTS = 30;
+const MAX_FEEDS = 4, MAX_UNITS = 256_000, MAX_AGE = 7 * 86400_000;
+export function createFeedCache(storage, prefix, now = Date.now) {
+ const keyOf = (id) => id === 'following' ? prefix : prefix + ':' + id;
+ const valid = (e) => e?.kind === 1 && typeof e.id === 'string' && typeof e.pubkey === 'string'
+ && typeof e.content === 'string' && Number.isFinite(e.created_at) && Array.isArray(e.tags);
+ function compact(notes) {
+ const kept = []; let size = 0;
+ for (const e of (Array.isArray(notes) ? notes : []).filter(valid).sort((a, b) => b.created_at - a.created_at)) {
+ const cost = JSON.stringify(e).length;
+ if (size + cost > MAX_UNITS - 1000) continue;
+ kept.push(e); size += cost;
+ if (kept.length === FEED_CACHE_POSTS) break;
+ }
+ return kept;
+ }
+ function sweep(preferred) {
+ const entries = [];
+ for (let i = 0; i < storage.length; i++) {
+ const key = storage.key(i);
+ if (key !== prefix && !key?.startsWith(prefix + ':')) continue;
+ try {
+ const text = storage.getItem(key);
+ const raw = JSON.parse(text);
+ const entry = Array.isArray(raw) ? { at: now(), notes: compact(raw) } : raw;
+ if (!entry || !Number.isFinite(entry.at) || now() - entry.at > MAX_AGE) entries.push({ key });
+ else entries.push({ key, text, value: { at: entry.at, notes: compact(entry.notes) } });
+ } catch { entries.push({ key }); }
+ }
+ entries.sort((a, b) => (b.key === preferred) - (a.key === preferred) || (b.value?.at || 0) - (a.value?.at || 0));
+ let units = 0, count = 0;
+ for (const e of entries) {
+ const json = e.value && JSON.stringify(e.value);
+ if (!json || count >= MAX_FEEDS || units + json.length > MAX_UNITS) storage.removeItem(e.key);
+ else { if (e.text !== json) storage.setItem(e.key, json); units += json.length; count++; }
+ }
+ }
+ return {
+ read(id) {
+ try {
+ sweep(keyOf(id));
+ const value = JSON.parse(storage.getItem(keyOf(id)) || 'null');
+ if (!value) return [];
+ value.at = now(); storage.setItem(keyOf(id), JSON.stringify(value));
+ return value.notes;
+ } catch { return []; }
+ },
+ save(id, notes) {
+ try { storage.setItem(keyOf(id), JSON.stringify({ at: now(), notes: compact(notes) })); sweep(keyOf(id)); } catch {}
+ },
+ };
+}
diff --git a/tools/feed-cache-test.js b/tools/feed-cache-test.js
new file mode 100644
index 0000000..995e01a
--- /dev/null
+++ b/tools/feed-cache-test.js
@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict';
+import { createFeedCache } from '../src/feed-cache.js';
+const values = new Map();
+const storage = { get length() { return values.size; }, key: (i) => [...values.keys()][i],
+ getItem: (k) => values.get(k) || null, setItem: (k, v) => values.set(k, v), removeItem: (k) => values.delete(k) };
+let time = 1000;
+const cache = createFeedCache(storage, 'wallet:feedNotes', () => time++);
+const notes = Array.from({ length: 50 }, (_, i) => ({ id: String(i), pubkey: 'author', kind: 1,
+ created_at: 100 - i, content: 'post', tags: [] }));
+storage.setItem('wallet:feedNotes', JSON.stringify(notes));
+storage.setItem('wallet:funds', 'untouched');
+assert.equal(cache.read('following').length, 30);
+assert.equal(JSON.parse(storage.getItem('wallet:feedNotes')).notes.length, 30);
+for (let i = 0; i < 5; i++) cache.save('custom' + i, notes);
+assert.equal(cache.read('following').length, 0);
+assert.equal([...values.keys()].filter((k) => k.includes('feedNotes')).length, 4);
+cache.read('custom1');
+cache.save('custom5', notes);
+assert.equal(cache.read('custom2').length, 0);
+cache.save('big', notes.map((e) => ({ ...e, content: 'x'.repeat(15000) })));
+assert([...values].filter(([k]) => k.includes('feedNotes')).reduce((n, [, v]) => n + v.length, 0) <= 256000);
+time += 8 * 86400_000;
+assert.equal(cache.read('big').length, 0);
+assert.equal(storage.getItem('wallet:funds'), 'untouched');
+console.log('✓ legacy caches shrink; 30 posts/feed, four feeds, byte budget, LRU and seven-day expiry enforced');
diff --git a/tools/feed-stability-test.js b/tools/feed-stability-test.js
index 1fdc5ba..a1b3a1e 100644
--- a/tools/feed-stability-test.js
+++ b/tools/feed-stability-test.js
@@ -24,7 +24,7 @@ const seeded = Array.from({ length: 45 }, (_, i) => event('Cached post ' + i + '
seeded[1] = event('With a quote nostr:' + noteEncode(quote.id) + ' and @' + npubEncode(other), now - 660);
seeded[3] = event('Unavailable picture ' + origin + '/late.png', now - 780);
seeded[8] = event('Five pictures ' + Array.from({ length: 5 }, (_, i) => origin + '/gallery' + i + '.png').join(' '), now - 1080);
-seeded[24] = event('Next page picture ' + origin + '/next.png', now - 2040);
+seeded[12] = event('Next page picture ' + origin + '/next.png', now - 1320);
const requested = new Map();
const html = await buildHtml({ minify: true, pwa: false });
const server = Bun.serve({ port: 5298, fetch: async (req) => {
@@ -57,6 +57,7 @@ await page.evaluateOnNewDocument((fixtures) => {
if (m[0] === 'CLOSE') { this.subs.delete(m[1]); return; }
if (m[0] !== 'REQ') return;
const [_, id, filter] = m;
+ (window.__feedRequests ||= []).push(filter);
this.subs.set(id, filter);
let events = [];
if (filter.kinds?.includes(0) && filter.authors?.includes(fixtures.profile.pubkey)) events = [fixtures.profile];
@@ -105,6 +106,9 @@ try {
}, [base, pk, seeded, origin]);
await page.reload();
await page.waitForFunction(() => document.body.innerText.toLowerCase().includes('receive'));
+ await sleep(2000);
+ check('a small feed query starts before opening the feed', await page.evaluate(() =>
+ !document.querySelector('.notes-feed') && (window.__feedRequests || []).some((f) => f.kinds?.length === 1 && f.kinds[0] === 1 && f.limit === 10)));
await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || ''))?.click());
await page.waitForSelector('.item');
await page.evaluate(() => [...document.querySelectorAll('.item')].find((e) => /feed/i.test(e.textContent))?.click());
@@ -122,12 +126,12 @@ try {
// With the header visible, a prepend must wait: anchoring only the first
// post would otherwise scroll the header offscreen.
- const headerBefore = await page.evaluate(() => ({ y: scrollY, text: document.querySelector('.chat-page').innerText }));
+ const headerBefore = await page.evaluate(() => ({ y: scrollY, text: document.querySelector('.chat-page > .row').innerText }));
const fresh = event('A new live post', now + 1);
check('a live subscription received the event', await page.evaluate((e) => window.__inject(e), fresh) > 0);
await sleep(1000);
check('live arrival leaves the visible header and posts untouched', await page.evaluate((before) =>
- scrollY === before.y && document.querySelector('.chat-page').innerText === before.text, headerBefore));
+ scrollY === before.y && document.querySelector('.chat-page > .row').innerText === before.text, headerBefore));
check('new post waits until it can be inserted offscreen', !(await page.$(row(fresh.id))));
// Once the reader scrolls the header away, preserve every visible row on
@@ -169,8 +173,8 @@ try {
check('the gap fill is admitted once offscreen', !!(await page.$(row(gap.id))));
await page.$eval(row(seeded[18].id), (n) => n.scrollIntoView());
- await page.waitForSelector(row(seeded[24].id), { timeout: 10000 });
- check('older posts reveal with decoded pictures', await page.$eval(row(seeded[24].id), (n) => { const i = n.querySelector('.note-img'); return !!i && i.complete && i.naturalWidth > 0; }));
+ await page.waitForSelector(row(seeded[12].id), { timeout: 10000 });
+ check('older posts reveal with decoded pictures', await page.$eval(row(seeded[12].id), (n) => { const i = n.querySelector('.note-img'); return !!i && i.complete && i.naturalWidth > 0; }));
const before = (await snapshot()).find((r) => r.top + r.height > 0);
await page.evaluate((evs) => {
@@ -186,6 +190,22 @@ try {
await page.waitForFunction(() => document.querySelector('.notes-feed')?.innerText.includes('Large catch-up 24'), { timeout: 15000 });
const after = (await snapshot()).find((r) => r.id === before.id);
check('a large catch-up preserves the reading position', !!after && Math.abs(after.top - before.top) <= 1, JSON.stringify({ before: before.top, after: after?.top }));
+ // Start on the wallet and let its small background warm-up finish. The
+ // first feed tap must paint rows synchronously, without a loading frame.
+ await page.evaluate(([base, notes]) => {
+ localStorage.setItem(base + ':feedNotes', JSON.stringify(notes));
+ const nav = { ...history.state.nav, screen: 'wallet', tab: 'history', chatOpen: false,
+ noteThread: null, profilePk: null, msgView: 'home' };
+ history.replaceState({ nav, i: 0 }, '', '/');
+ }, [base, seeded.filter((_, i) => ![1, 3, 8, 12].includes(i)).slice(0, 10)]);
+ await page.reload();
+ await sleep(5500);
+ await page.evaluate(() => [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || ''))?.click());
+ await page.waitForSelector('.item');
+ check('first opening after background preparation has rows and no spinner', await page.evaluate(() => {
+ [...document.querySelectorAll('.item')].find((e) => /feed/i.test(e.textContent))?.click();
+ return !!document.querySelector('.notes-feed > .row') && !document.querySelector('.chat-page .spinner');
+ }));
check('no browser errors', !errors.length, errors.join(' | '));
} finally { await browser.close(); server.stop(true); }
process.exit(ok ? 0 : 1);