// 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 }); const savedProfile = (p) => { if (!p || typeof p !== 'object') return null; const name = typeof p.name === 'string' ? p.name.slice(0, 120) : null; const picture = typeof p.picture === 'string' ? p.picture.slice(0, 1000) : null; if (!name && !picture) return null; const out = { name, picture, eventAt: Number.isFinite(p.eventAt) ? p.eventAt : 0, t: Number.isFinite(p.t) ? p.t : 0 }; if (picture && p.thumbFor === picture && typeof p.thumb === 'string' && p.thumb.length <= 16000 && /^data:image\/(?:webp|jpeg|png);base64,/.test(p.thumb)) { out.thumb = p.thumb; out.thumbFor = picture; out.thumbPx = p.thumbPx || 0; } return out; }; const savedCounts = (c) => { if (!c || typeof c !== 'object') return null; const count = (n) => Number.isSafeInteger(n) && n >= 0 && n <= 1_000_000_000 ? n : 0; return { likes: count(c.likes), boosts: count(c.boosts), sats: count(c.sats) }; }; 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, profiles = {}, counts = {}) { 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 previous = read().find((c) => c.rootId === thread.root.id); const authors = [...new Set([...keep.values()].map((e) => e.pubkey))].slice(0, 24); const faces = {}; for (const pk of authors) { const fresh = savedProfile(profiles[pk]); const old = savedProfile(previous?.profiles?.[pk]); const face = !fresh || (old && old.eventAt > fresh.eventAt) ? old : fresh; if (face === fresh && old?.thumb && !fresh.thumb && old.picture === fresh.picture) { face.thumb = old.thumb; face.thumbFor = old.thumbFor; face.thumbPx = old.thumbPx; } if (face) faces[pk] = face; } const tallies = {}; for (const id of keep.keys()) { const tally = savedCounts(counts[id] || previous?.counts?.[id]); if (tally) tallies[id] = tally; } const entry = { rootId: thread.root.id, root: savedNote(thread.root), profiles: faces, counts: tallies, 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 }, }; }