eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// Message search over the local store (#586) — the web twin of the native
2// MessageStore.search (Mac header / iOS thread list).
3//
4// The store holds CIPHERTEXT rows (store.js), so searching means decrypting —
5// done lazily into an in-MEMORY index (never written anywhere): each thread
6// decrypts once per session, then queries are instant scans. At this
7// product's scale a substring scan is plenty — no FTS engine in the browser,
8// matching the issue's call. Decrypt is injected, so the index is Node-testable.
9//
10// Memory: the index holds each ensured thread's decrypted text for the
11// session — roughly the size of the conversation itself (tens of MB only at
12// the far tail of huge, fully-backfilled spaces). Deliberately uncapped: an
13// eviction scheme would silently narrow search coverage; the tab's lifetime
14// bounds it. Revisit if a real space ever makes this hurt — the agreed
15// upgrade (a persisted, encrypted-at-rest term index; triggers, shape, and
16// invariants) is docs/sealed-search-index.md.
17
18// Pure: does `text` match the query? Case-insensitive; every whitespace-
19// separated term must appear (AND) — the familiar chat-search behaviour.
20export function matchText(text, query) {
21 if (!text || !query) return false;
22 const hay = text.toLowerCase();
23 const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
24 return terms.length > 0 && terms.every((t) => hay.includes(t));
25}
26
27// Pure: a readable window around the first hit, ellipsised at cut edges.
28export function snippetFor(text, query, len = 90) {
29 const t = (text || "").replace(/\s+/g, " ").trim();
30 if (t.length <= len) return t;
31 const first = (query || "").toLowerCase().split(/\s+/).filter(Boolean)[0] || "";
32 const at = first ? t.toLowerCase().indexOf(first) : 0;
33 const start = Math.max(0, Math.min(at - Math.floor(len / 3), t.length - len));
34 const cut = t.slice(start, start + len);
35 return (start > 0 ? "…" : "") + cut + (start + len < t.length ? "…" : "");
36}
37
38// A session-scoped decrypted-text index. Rows arrive as raw store rows
39// ({id, created_at, blob, tombstone_of}); decryptRow(row, tid) resolves
40// { text, name, ts } or null (undecryptable / typed app event / reaction —
41// not searchable prose). Incremental per thread by a seen-id SET, not a
42// high-water mark: rows arrive out of ID order by design — live arrivals are
43// newer, but a backfill walk (#457) delivers ever-OLDER pages, and both must
44// index exactly once.
45export class SearchIndex {
46 constructor(decryptRow) {
47 this.decryptRow = decryptRow;
48 this.threads = new Map(); // tid → { seen: Set<id>, entries: [{id, ts, text, name}] }
49 }
50
51 async ensureThread(tid, rows) {
52 let t = this.threads.get(tid);
53 if (!t) {
54 t = { seen: new Set(), entries: [] };
55 this.threads.set(tid, t);
56 }
57 for (const row of rows) {
58 if (!row || !row.id || t.seen.has(row.id)) continue;
59 t.seen.add(row.id);
60 if (row.tombstone_of) {
61 // The unsent message must stop matching — drop it from the index too.
62 const gone = row.tombstone_of;
63 t.entries = t.entries.filter((e) => e.id !== gone);
64 continue;
65 }
66 const d = await this.decryptRow(row, tid);
67 if (d && d.text) t.entries.push({ id: row.id, ts: d.ts || row.created_at * 1000 || 0, text: d.text, name: d.name || "" });
68 }
69 return t;
70 }
71
72 // Forget one message (a live delete frame) or a whole thread (left it).
73 dropMessage(tid, id) {
74 const t = this.threads.get(tid);
75 if (t) t.entries = t.entries.filter((e) => e.id !== id);
76 }
77 dropThread(tid) {
78 this.threads.delete(tid);
79 }
80
81 // Newest-first matches across every ensured thread — native parity
82 // (MessageStore.search: instance-wide, limit 50).
83 query(q, limit = 50) {
84 const out = [];
85 for (const [tid, t] of this.threads) {
86 for (const e of t.entries) {
87 if (matchText(e.text, q)) out.push({ tid, ...e });
88 }
89 }
90 out.sort((a, b) => b.id - a.id);
91 return out.slice(0, limit);
92 }
93}
94