diff options
| author | Adam Soltys <[email protected]> | 2026-09-29 07:48:59 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-29 07:48:59 -0700 |
| commit | b9c04f573567d69d94fb120aa8feed89cf75585c (patch) | |
| tree | ff042c6e988c711fa16c605edc4465e1c68aab61 | |
| parent | d5598a131a9d160b929ff7ccbd650a4ef57b21c0 (diff) | |
| download | coinosv3-b9c04f573567d69d94fb120aa8feed89cf75585c.tar.gz coinosv3-b9c04f573567d69d94fb120aa8feed89cf75585c.zip | |
Cache notification note content locally
| -rw-r--r-- | src/features/messages.js | 73 | ||||
| -rw-r--r-- | tools/notif-note-cache-test.js | 128 |
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); |
