aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-29 07:48:59 -0700
committerAdam Soltys <[email protected]>2026-09-29 07:48:59 -0700
commitb9c04f573567d69d94fb120aa8feed89cf75585c (patch)
treeff042c6e988c711fa16c605edc4465e1c68aab61
parentd5598a131a9d160b929ff7ccbd650a4ef57b21c0 (diff)
downloadcoinosv3-b9c04f573567d69d94fb120aa8feed89cf75585c.tar.gz
coinosv3-b9c04f573567d69d94fb120aa8feed89cf75585c.zip
Cache notification note content locally
-rw-r--r--src/features/messages.js73
-rw-r--r--tools/notif-note-cache-test.js128
2 files changed, 197 insertions, 4 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index 2cf799b..7505dbf 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -5346,6 +5346,7 @@ export function messagesFeature(ctx) {
const evs = await queryOn(relays, { ids: [ref.id] }, 4500).catch(() => []);
c.ev = (evs || [])[0] || c.ev || null;
c.status = c.ev ? 'ready' : 'missing';
+ if (c.ev) cacheNotifNotes([c.ev]);
// the open thread quotes it: keep it with the thread for the next reload
const th = c.ev && ui.noteThread && threadCache.get(ui.noteThread.rootId);
if (th && th.root) persistThread(th);
@@ -8983,6 +8984,7 @@ export function messagesFeature(ctx) {
const NOTIF_KEEP = 150;
let notif = null; // { status, items }
let notifAt = 0, notifUnsub = null;
+ const notifNoteAsking = new Set();
const notifSeen = () => st().notifSeen || 0;
// Notifications are about the IDENTITY, not the wallet: the stored list
// remembers whose it is, and another identity on the same wallet starts
@@ -8993,7 +8995,17 @@ export function messagesFeature(ctx) {
const s = st();
const stored = (!s.notifsPk || s.notifsPk === identityPk()) ? (s.notifs || []) : [];
notif = { status: stored.length ? 'ready' : 'loading', items: stored };
+ // Reply events and the notes reactions/zaps point at ride beside their
+ // notification rows. Seed the in-memory note caches synchronously, so
+ // opening Notifications after a reload never paints "Fetching note…".
+ const noteCache = s.notifNotesPk === identityPk() ? (s.notifNoteCache || {}) : {};
+ for (const note of Object.values(noteCache)) {
+ if (!note || !note.id) continue;
+ notifNotes.set(note.id, note);
+ quoted.set(note.id, { status: 'ready', ev: note, at: Date.now() });
+ }
warmNotifFaces(stored);
+ warmNotifNotes(stored);
refreshNotifs();
}
return notif;
@@ -9011,6 +9023,58 @@ export function messagesFeature(ctx) {
const deadline = Date.now() + READY_MS;
for (const pk of pks) warmAvatar(pk, deadline).catch(() => {});
}
+ // Keep only what rendering and opening a thread need. Signatures and relay
+ // metadata make this cache unnecessarily large; the relays remain the
+ // source of truth whenever the live notification refresh runs.
+ const savedNotifNote = (ev) => ev && ev.id ? ({
+ id: ev.id, pubkey: ev.pubkey, kind: ev.kind, created_at: ev.created_at,
+ content: ev.content || '', tags: ev.tags || [],
+ }) : null;
+ function persistNotifs() {
+ if (!notif) return;
+ const wanted = new Set();
+ for (const x of notif.items) {
+ const id = x.what === 'reply' || x.what === 'mention' ? x.id : x.target;
+ if (id) wanted.add(id);
+ }
+ const notes = {};
+ for (const id of wanted) if (notifNotes.has(id)) notes[id] = savedNotifNote(notifNotes.get(id));
+ const s2 = st();
+ s2.notifs = notif.items; s2.notifsPk = identityPk();
+ s2.notifNoteCache = notes; s2.notifNotesPk = identityPk();
+ save(s2);
+ }
+ function cacheNotifNotes(evs) {
+ if (!notif || !(evs || []).length) return false;
+ const byId = new Map((evs || []).filter((ev) => ev && ev.id).map((ev) => [ev.id, savedNotifNote(ev)]));
+ let changed = false;
+ const wanted = new Set();
+ for (const x of notif.items) wanted.add(x.what === 'reply' || x.what === 'mention' ? x.id : x.target);
+ for (const [id, note] of byId) {
+ if (!wanted.has(id) || notifNotes.has(id)) continue;
+ notifNotes.set(id, note);
+ quoted.set(id, { status: 'ready', ev: note, at: Date.now() });
+ changed = true;
+ }
+ if (changed) persistNotifs();
+ return changed;
+ }
+ // Old notification rows did not carry their note. Upgrade them in one
+ // bounded batch while the bell/chat home is visible, before the user opens
+ // the page. New rows take the same path as soon as they arrive.
+ function warmNotifNotes(items) {
+ const ids = [...new Set((items || [])
+ .filter((x) => x && x.target && x.what !== 'reply' && x.what !== 'mention' && !notifNotes.has(x.target))
+ .map((x) => x.target).filter((id) => !notifNoteAsking.has(id)))].slice(0, NOTIF_KEEP);
+ if (!ids.length) return;
+ for (const id of ids) notifNoteAsking.add(id);
+ (async () => {
+ const got = [];
+ for (let i = 0; i < ids.length; i += 60)
+ got.push(...await queryOn(zapRelays(), { ids: ids.slice(i, i + 60) }, 4500).catch(() => []));
+ if (cacheNotifNotes(got)) scheduleRepaint();
+ })().finally(() => { for (const id of ids) notifNoteAsking.delete(id); });
+ }
// What an event says happened, or null if it isn't about you after all.
function notifItem(ev) {
const my = myPubkeys();
@@ -9057,11 +9121,12 @@ export function messagesFeature(ctx) {
kept.push(x);
}
c.items = kept.slice(0, NOTIF_KEEP);
- // the rows already say who; a reply row also wants its reader-facing text,
- // which it carries — the events themselves are not kept
- const s2 = st(); s2.notifs = c.items; s2.notifsPk = identityPk(); save(s2);
- // a reply is a note we can open straight away; keep it in hand
+ // Keep the small notification rows and one deduplicated copy of each note
+ // they show. A reply is also a note we can open straight away.
for (const ev of evs) if (ev.kind === 1) notifNotes.set(ev.id, ev);
+ cacheNotifNotes(evs);
+ warmNotifNotes(c.items);
+ persistNotifs();
return true;
}
const notifNotes = new Map(); // reply id -> event, for opening the thread
diff --git a/tools/notif-note-cache-test.js b/tools/notif-note-cache-test.js
new file mode 100644
index 0000000..c0ceeca
--- /dev/null
+++ b/tools/notif-note-cache-test.js
@@ -0,0 +1,128 @@
+// Referenced notification notes are fetched before the page opens, persisted,
+// and available on the first paint after a reload.
+// Run: bun tools/notif-note-cache-test.js
+import puppeteer from 'puppeteer-core';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { buildHtml } from '../build.js';
+
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+let ok = true;
+const check = (name, pass, detail = '') => {
+ console.log(` ${pass ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`);
+ if (!pass) ok = false;
+};
+const now = Math.floor(Date.now() / 1000);
+const actorSk = generateSecretKey();
+const actorPk = getPublicKey(actorSk);
+const noteSk = generateSecretKey();
+const noteText = 'This notification excerpt came from the local note cache.';
+const target = finalizeEvent({ kind: 1, created_at: now - 120, tags: [], content: noteText }, noteSk);
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({
+ port: 5307,
+ fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path.startsWith('/punks') || path === '/verify-worker.js') return new Response(Bun.file('dist' + path));
+ return new Response(html, { headers: { 'content-type': 'text/html' } });
+ },
+});
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((targetEvent) => {
+ window.__targetReqs = 0;
+ const Real = window.WebSocket;
+ window.WebSocket = function (...args) {
+ const ws = new Real(...args);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const msg = JSON.parse(data);
+ if (msg[0] === 'REQ') {
+ const filter = msg[2] || {};
+ const hit = (filter.ids || []).includes(targetEvent.id);
+ if (hit) window.__targetReqs++;
+ setTimeout(() => {
+ if (hit) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', msg[1], targetEvent]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', msg[1]]) }));
+ }, 0);
+ return;
+ }
+ if (msg[0] === 'EVENT') return;
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, Real);
+}, target);
+
+const clickText = (text) => page.evaluate((needle) => {
+ const el = [...document.querySelectorAll('button')].find((node) => node.textContent.trim().toLowerCase().includes(needle));
+ if (!el) return false;
+ el.click(); return true;
+}, text);
+const waitText = async (text, timeout = 20_000) => {
+ for (let elapsed = 0; elapsed < timeout; elapsed += 200) {
+ if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(text.toLowerCase())) return true;
+ await sleep(200);
+ }
+ return false;
+};
+const openNotifications = () => page.evaluate(() => {
+ const el = [...document.querySelectorAll('button')].find((node) => /notifications/i.test(node.getAttribute('aria-label') || ''));
+ if (!el) return false;
+ el.click(); return true;
+});
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5307/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await clickText('create a new wallet');
+ await page.waitForSelector('.words .w .t');
+ const mnemonic = (await page.$$eval('.words .w .t', (nodes) => nodes.map((node) => node.textContent.trim()))).join(' ');
+ await clickText('skip verification');
+ await waitText('receive');
+ const base = cacheKeyFor(mnemonic + '\n');
+ const me = getPublicKey(privateKeyFromSeedWords(mnemonic));
+ const item = { id: 'a'.repeat(64), what: 'react', actor: actorPk, target: target.id, hint: [], emoji: '❤️', ts: now - 30 };
+ await page.evaluate(([key, owner, notification]) => {
+ const state = JSON.parse(localStorage.getItem(key + ':messages') || '{}');
+ state.notifs = [notification]; state.notifsPk = owner;
+ localStorage.setItem(key + ':messages', JSON.stringify(state));
+ }, [base, me, item]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+
+ let cached = false;
+ for (let i = 0; i < 75; i++) {
+ cached = await page.evaluate(([key, id]) => {
+ const state = JSON.parse(localStorage.getItem(key + ':messages') || '{}');
+ return state.notifNoteCache && state.notifNoteCache[id]?.content;
+ }, [base, target.id]);
+ if (cached) break;
+ await sleep(200);
+ }
+ check('the referenced note is warmed and persisted', cached === noteText);
+ check('the relay was queried while the page was still closed', (await page.evaluate(() => window.__targetReqs)) > 0);
+
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive');
+ check('notifications opens from the bottom navigation', await openNotifications());
+ await sleep(80);
+ const firstPaint = await page.evaluate(() => document.body.innerText);
+ check('the cached excerpt is present immediately', firstPaint.includes('This notification excerpt'));
+ check('there is no Fetching note placeholder', !firstPaint.includes('Fetching note'));
+ check('the persisted note avoids another target query', (await page.evaluate(() => window.__targetReqs)) === 0);
+} finally {
+ await browser.close();
+ server.stop(true);
+}
+
+console.log(ok ? '\n✅ notification note cache works' : '\n❌ notification note cache failed');
+process.exit(ok ? 0 : 1);