// Message search over the local store (#586) — the web twin of the native // MessageStore.search (Mac header / iOS thread list). // // The store holds CIPHERTEXT rows (store.js), so searching means decrypting — // done lazily into an in-MEMORY index (never written anywhere): each thread // decrypts once per session, then queries are instant scans. At this // product's scale a substring scan is plenty — no FTS engine in the browser, // matching the issue's call. Decrypt is injected, so the index is Node-testable. // // Memory: the index holds each ensured thread's decrypted text for the // session — roughly the size of the conversation itself (tens of MB only at // the far tail of huge, fully-backfilled spaces). Deliberately uncapped: an // eviction scheme would silently narrow search coverage; the tab's lifetime // bounds it. Revisit if a real space ever makes this hurt — the agreed // upgrade (a persisted, encrypted-at-rest term index; triggers, shape, and // invariants) is docs/sealed-search-index.md. // Pure: does `text` match the query? Case-insensitive; every whitespace- // separated term must appear (AND) — the familiar chat-search behaviour. export function matchText(text, query) { if (!text || !query) return false; const hay = text.toLowerCase(); const terms = query.toLowerCase().split(/\s+/).filter(Boolean); return terms.length > 0 && terms.every((t) => hay.includes(t)); } // Pure: a readable window around the first hit, ellipsised at cut edges. export function snippetFor(text, query, len = 90) { const t = (text || "").replace(/\s+/g, " ").trim(); if (t.length <= len) return t; const first = (query || "").toLowerCase().split(/\s+/).filter(Boolean)[0] || ""; const at = first ? t.toLowerCase().indexOf(first) : 0; const start = Math.max(0, Math.min(at - Math.floor(len / 3), t.length - len)); const cut = t.slice(start, start + len); return (start > 0 ? "…" : "") + cut + (start + len < t.length ? "…" : ""); } // A session-scoped decrypted-text index. Rows arrive as raw store rows // ({id, created_at, blob, tombstone_of}); decryptRow(row, tid) resolves // { text, name, ts } or null (undecryptable / typed app event / reaction — // not searchable prose). Incremental per thread by a seen-id SET, not a // high-water mark: rows arrive out of ID order by design — live arrivals are // newer, but a backfill walk (#457) delivers ever-OLDER pages, and both must // index exactly once. export class SearchIndex { constructor(decryptRow) { this.decryptRow = decryptRow; this.threads = new Map(); // tid → { seen: Set, entries: [{id, ts, text, name}] } } async ensureThread(tid, rows) { let t = this.threads.get(tid); if (!t) { t = { seen: new Set(), entries: [] }; this.threads.set(tid, t); } for (const row of rows) { if (!row || !row.id || t.seen.has(row.id)) continue; t.seen.add(row.id); if (row.tombstone_of) { // The unsent message must stop matching — drop it from the index too. const gone = row.tombstone_of; t.entries = t.entries.filter((e) => e.id !== gone); continue; } const d = await this.decryptRow(row, tid); if (d && d.text) t.entries.push({ id: row.id, ts: d.ts || row.created_at * 1000 || 0, text: d.text, name: d.name || "" }); } return t; } // Forget one message (a live delete frame) or a whole thread (left it). dropMessage(tid, id) { const t = this.threads.get(tid); if (t) t.entries = t.entries.filter((e) => e.id !== id); } dropThread(tid) { this.threads.delete(tid); } // Newest-first matches across every ensured thread — native parity // (MessageStore.search: instance-wide, limit 50). query(q, limit = 50) { const out = []; for (const [tid, t] of this.threads) { for (const e of t.entries) { if (matchText(e.text, q)) out.push({ tid, ...e }); } } out.sort((a, b) => b.id - a.id); return out.slice(0, limit); } }