eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Message search (#586) — the search box's index wiring, result list, deep
2// back-fill walk, and jump-to-message. The SearchIndex itself lives in
3// search.js; the local store feeds it. Extracted verbatim from shell.js —
4// state via shellstate.js; navigation to a result's thread is the injected
5// openThread hook (the one shell behaviour this needs).
6import { SearchIndex, snippetFor } from "/static/search.js";
7import { allMessages as storeAllMessages, getBackfillMark as storeBackfillMark, runBackfill as storeRunBackfill } from "/static/store.js";
8import { decrypt } from "/static/crypto.js";
9import { scrollToMessage } from "/static/apps/chat.js";
10import { session, threads, threadKeys, myUserId, threadName } from "/static/shellstate.js";
11import { relTime } from "/static/format.js";
12
13// The shell behaviours search rides: navigation into a result's thread, the
14// chat-thread predicate, scroll-back paging for the deep walk, and the chat
15// list's avatar painter (so Conversations rows wear the same faces).
16let openThreadFn = () => {};
17let chatAppFor = () => null;
18let chatAppLoadOlder = async () => {};
19let threadAvatarFn = () => document.createElement("span");
20export function wireSearch(deps) {
21 ({ openThread: openThreadFn, chatAppFor, chatAppLoadOlder, threadAvatar: threadAvatarFn } = deps);
22}
23
24// ---- message search (#586) -------------------------------------------------
25// Search over the local store: rows decrypt lazily into an in-MEMORY index
26// (search.js — never written anywhere), then queries are instant scans.
27// Everything is local; the query never leaves the device. Results render in
28// a dropdown under the search box (#msg-results); a row jumps
29// to the message in its thread. "Search earlier messages" walks incomplete
30// threads' full history into the store (runBackfill), streaming new hits in,
31// cancellable — the #228 time machine.
32export const searchIdx = new SearchIndex(async (row, tid) => {
33 try {
34 const data = await decrypt(threadKeys[tid], row.blob);
35 // Only plain chat prose is searchable — typed app events and reactions
36 // have no text a member would hunt for.
37 if (data.type || !data.text) return null;
38 return { text: data.text, name: data.name || "", ts: data.ts || row.created_at * 1000 };
39 } catch {
40 return null;
41 }
42});
43
44// The threads search covers: every chat-feed thread we hold a key for.
45function searchableThreads() {
46 return threads.filter((t) => chatAppFor(t.id) && threadKeys[t.id]);
47}
48
49let searchSeq = 0;
50let searchTimer = null;
51let searchBackfill = null; // { controller } while the deep walk runs
52let searchCursorKey = null; // "tid:sid" of the keyboard-highlighted row; null → the top hit
53
54export function scheduleMessageSearch(query) {
55 clearTimeout(searchTimer);
56 const box = document.getElementById("msg-results");
57 if (!box) return;
58 if (!query || query.length < 2) {
59 // Leaving search cancels any deep walk — its watermark persists, so a
60 // later "Search earlier messages" resumes where it stopped.
61 if (searchBackfill) searchBackfill.controller.abort();
62 searchSeq++;
63 searchCursorKey = null;
64 box.classList.add("hidden");
65 box.textContent = "";
66 const filter = document.getElementById("chat-filter");
67 if (filter) {
68 filter.setAttribute("aria-expanded", "false");
69 filter.removeAttribute("aria-activedescendant");
70 }
71 return;
72 }
73 searchCursorKey = null; // an edited query re-highlights the top hit
74 searchTimer = setTimeout(() => runMessageSearch(query), 250);
75}
76
77// Ending the search goes through the real input path — value cleared plus an
78// input event — so the name-filtered chat list resets in the same breath
79// (shell.js owns that wiring) and scheduleMessageSearch hides the dropdown.
80function clearSearch(refocus) {
81 const filter = document.getElementById("chat-filter");
82 if (!filter) return;
83 filter.value = "";
84 filter.dispatchEvent(new Event("input", { bubbles: true }));
85 if (refocus) filter.focus();
86}
87
88// Scaffold rebuilds can remove the node holding focus; when that happens
89// (focus fell to <body>) hand it back to the search box rather than stranding
90// a keyboard user at the top of the document.
91function rescueFocus() {
92 if (document.activeElement !== document.body) return;
93 const filter = document.getElementById("chat-filter");
94 if (filter) filter.focus();
95}
96
97// The dropdown dismisses like the filter menu beside it: a click anywhere
98// outside the search bar and the results ends the search. (A pick ends it
99// too — the row's own onclick — before this listener sees the click.)
100document.addEventListener("click", (e) => {
101 const box = document.getElementById("msg-results");
102 if (!box || box.classList.contains("hidden")) return;
103 const bar = document.getElementById("chat-search");
104 if (box.contains(e.target) || (bar && bar.contains(e.target))) return;
105 clearSearch(false);
106});
107
108// Every navigable row in the dropdown — Conversations and Messages alike —
109// in visual order (section headers are presentational, never cursor stops).
110function optionRows(s) {
111 return [...s.list.querySelectorAll('[role="option"]')];
112}
113
114// The keyboard cursor: focus never leaves the search box — the highlighted
115// row (top hit by default, so plain Enter opens it with no arrowing) is a
116// virtual cursor the box's aria-activedescendant tracks, combobox-style.
117function setCursor(s, row) {
118 for (const r of optionRows(s)) {
119 r.classList.toggle("active", r === row);
120 r.setAttribute("aria-selected", String(r === row));
121 }
122 const filter = document.getElementById("chat-filter");
123 if (filter) {
124 if (row) filter.setAttribute("aria-activedescendant", row.id);
125 else filter.removeAttribute("aria-activedescendant");
126 }
127 searchCursorKey = row ? row.dataset.key : null;
128}
129
130// Keyboard: Down/Up move the highlight from the search box (or a Tab-focused
131// row), Enter in the box opens the highlighted hit, Escape ends the search
132// from anywhere in it. Rows are real buttons, so Tab + Enter/Space on a row
133// already behave.
134document.addEventListener("keydown", (e) => {
135 if (e.isComposing) return; // IME owns Enter/arrows mid-composition
136 const box = document.getElementById("msg-results");
137 if (!box || box.classList.contains("hidden")) return;
138 const filter = document.getElementById("chat-filter");
139 if (e.target !== filter && !box.contains(e.target)) return;
140 if (e.key === "Escape") {
141 e.preventDefault();
142 clearSearch(true);
143 return;
144 }
145 const s = resultsScaffold();
146 if (!s) return;
147 const rows = optionRows(s);
148 const cur = rows.findIndex((r) => r.classList.contains("active"));
149 if (e.key === "ArrowDown" || e.key === "ArrowUp") {
150 e.preventDefault();
151 if (!rows.length) return;
152 const next = e.key === "ArrowDown" ? Math.min(cur + 1, rows.length - 1) : Math.max(cur - 1, 0);
153 setCursor(s, rows[next]);
154 rows[next].scrollIntoView({ block: "nearest" });
155 return;
156 }
157 if (e.key === "Enter" && e.target === filter && cur >= 0) {
158 e.preventDefault();
159 rows[cur].click();
160 }
161});
162
163async function runMessageSearch(query) {
164 const mySeq = ++searchSeq;
165 // Conversations (and any already-indexed hits) paint before the first
166 // decrypt — a name search answers instantly, whatever the index is up to.
167 renderSearchResults(query);
168 // Stream: render after each thread's ensure, so a big space's first search
169 // shows hits as they decrypt instead of a long blank box.
170 for (const t of searchableThreads()) {
171 await searchIdx.ensureThread(t.id, await storeAllMessages(t.id));
172 if (mySeq !== searchSeq) return; // superseded while decrypting
173 renderSearchResults(query);
174 }
175}
176
177// The results scaffold persists across renders: ONE aria-live status node
178// whose text mutates (a recreated live region isn't reliably announced, and
179// two would double-speak), a list rebuilt per render, and a single footer
180// slot (the deeper-search affordance / the walk's Stop control — the latter
181// outside the live region so it isn't re-announced).
182function resultsScaffold() {
183 const box = document.getElementById("msg-results");
184 if (!box) return null;
185 if (!box.firstChild) {
186 const status = document.createElement("div");
187 status.className = "msg-results-count";
188 status.setAttribute("aria-live", "polite");
189 const list = document.createElement("div");
190 list.id = "msg-results-list"; // the search box's aria-controls target
191 list.setAttribute("role", "listbox");
192 list.setAttribute("aria-label", "Search results");
193 const footer = document.createElement("div");
194 box.append(status, list, footer);
195 }
196 const [status, list, footer] = box.children;
197 return { box, status, list, footer };
198}
199
200// Conversations whose name matches the query — the same predicate the chat
201// list filters by, so the dropdown finds exactly what the (covered) list
202// would show, closed chats included. Most recent first, capped so message
203// hits keep the room.
204function conversationHits(query) {
205 const q = query.toLowerCase();
206 return threads
207 .filter((t) => (threadName[t.id] || "").toLowerCase().includes(q))
208 .sort((a, b) => (b.last_at || b.created_at || 0) - (a.last_at || a.created_at || 0))
209 .slice(0, 8);
210}
211
212// A muted section header inside the listbox — visual structure only, so the
213// keyboard cursor and aria-activedescendant skip straight over it.
214function sectionHeader(text) {
215 const h = document.createElement("div");
216 h.className = "msg-results-count";
217 h.setAttribute("role", "presentation");
218 h.textContent = text;
219 return h;
220}
221
222function renderSearchResults(query) {
223 const s = resultsScaffold();
224 if (!s) return;
225 const convs = conversationHits(query);
226 const hits = searchIdx.query(query);
227 s.box.classList.remove("hidden");
228 // One live region, one voice: the summary covers both sections (and the
229 // deep walk's narration below).
230 const msgPart = hits.length
231 ? `${hits.length}${hits.length === 50 ? "+" : ""} message${hits.length === 1 ? "" : "s"}`
232 : "No messages found";
233 let statusText = convs.length
234 ? `${convs.length} conversation${convs.length === 1 ? "" : "s"} · ${msgPart}`
235 : hits.length
236 ? msgPart
237 : "No matches found";
238 if (searchBackfill) statusText += ` · ${searchBackfill.progress}`;
239 if (s.status.textContent !== statusText) s.status.textContent = statusText;
240 s.list.textContent = "";
241 // ---- Conversations: matching chats, straight to the thread ---------------
242 // The consolidated dropdown covers the chat list it floats over — so the
243 // chats a name-search finds must be clickable HERE, not under it.
244 if (convs.length) {
245 s.list.appendChild(sectionHeader("Conversations"));
246 for (const t of convs) {
247 const row = document.createElement("button");
248 row.type = "button";
249 row.className = "msg-result conv-result";
250 row.appendChild(threadAvatarFn(t));
251 const name = document.createElement("span");
252 name.className = "conv-result-name";
253 name.textContent = threadName[t.id] || "Chat";
254 row.appendChild(name);
255 row.dataset.key = `t:${t.id}`;
256 row.id = `conv-hit-${t.id}`; // aria-activedescendant target
257 row.setAttribute("role", "option");
258 row.onclick = () => {
259 clearSearch(false);
260 openThreadFn(t.id);
261 };
262 s.list.appendChild(row);
263 }
264 if (hits.length) s.list.appendChild(sectionHeader("Messages"));
265 }
266 for (const h of hits) {
267 const row = document.createElement("button");
268 row.type = "button";
269 row.className = "msg-result";
270 const top = document.createElement("div");
271 top.className = "msg-result-top";
272 const who = document.createElement("span");
273 who.className = "msg-result-who";
274 who.textContent = h.name || "Someone";
275 const where = document.createElement("span");
276 where.className = "msg-result-where";
277 where.textContent = `${threadName[h.tid] || "Chat"} · ${relTime(Math.floor(h.ts / 1000))}`;
278 top.append(who, where);
279 const snip = document.createElement("div");
280 snip.className = "msg-result-snippet";
281 snip.textContent = snippetFor(h.text, query);
282 row.append(top, snip);
283 row.dataset.key = `${h.tid}:${h.id}`; // the cursor follows a hit across streamed rebuilds
284 row.id = `msg-hit-${h.tid}-${h.id}`; // aria-activedescendant target
285 row.setAttribute("role", "option");
286 row.onclick = () => {
287 // A pick ends the search — dropdown closed, chat list unfiltered —
288 // then lands on the message; scrollToMessage's flash marks the spot
289 // (no sticky highlight to outlive a search that's already over).
290 clearSearch(false);
291 jumpToMessage(h.tid, h.id);
292 };
293 s.list.appendChild(row);
294 }
295 // The cursor lands on the top hit unless the member had arrowed to a hit
296 // that's still in the (streamed re-)rendered list.
297 const rows = optionRows(s);
298 const kept = rows.find((r) => r.dataset.key === searchCursorKey);
299 setCursor(s, kept || rows[0] || null);
300 const filter = document.getElementById("chat-filter");
301 if (filter) filter.setAttribute("aria-expanded", "true");
302 renderSearchFooter(query);
303}
304
305// The footer slot: "Search earlier messages…" while any thread's history is
306// partial, or the running walk's Stop control. Guarded by searchSeq so a slow
307// mark read can't append a stale button over a newer render's footer.
308async function renderSearchFooter(query) {
309 const s = resultsScaffold();
310 if (!s) return;
311 if (searchBackfill) {
312 s.footer.replaceChildren(searchBackfill.cancel);
313 rescueFocus();
314 return;
315 }
316 const mySeq = searchSeq;
317 const incomplete = [];
318 for (const t of searchableThreads()) {
319 const mark = await storeBackfillMark(t.id);
320 if (!mark || !mark.complete) incomplete.push(t.id);
321 }
322 if (mySeq !== searchSeq || searchBackfill) return; // superseded meanwhile
323 const hadFocus = s.footer.contains(document.activeElement);
324 s.footer.textContent = "";
325 if (!incomplete.length) {
326 rescueFocus();
327 return;
328 }
329 const btn = document.createElement("button");
330 btn.type = "button";
331 btn.className = "msg-result-deeper";
332 btn.textContent = "Search earlier messages…";
333 btn.onclick = () => startDeepSearch(query, incomplete);
334 s.footer.replaceChildren(btn);
335 if (hadFocus) btn.focus(); // the affordance keeps focus across its own rebuild
336}
337
338async function startDeepSearch(query, threadIds) {
339 if (searchBackfill) return; // one walk at a time
340 const controller = new AbortController();
341 const cancel = document.createElement("button");
342 cancel.type = "button";
343 cancel.className = "msg-result-cancel";
344 cancel.textContent = "Stop searching";
345 cancel.onclick = () => controller.abort();
346 searchBackfill = { controller, cancel, progress: "Searching…" };
347 renderSearchResults(query); // swaps the footer button for Stop (sync up to the footer's first await)
348 cancel.focus(); // the deeper-button press continues on the walk's one control
349 try {
350 for (const tid of threadIds) {
351 if (controller.signal.aborted) break;
352 await storeRunBackfill(tid, (before) => fetchMessagePage(tid, before), {
353 signal: controller.signal,
354 onPage: async ({ oldestAt, rows }) => {
355 // Narrate how deep we've dug; index ONLY this page's rows (the
356 // seen-set dedupes) — re-reading the whole thread per page would
357 // make a long walk O(pages²).
358 if (searchBackfill) {
359 const d = oldestAt ? new Date(oldestAt * 1000) : null;
360 searchBackfill.progress = d
361 ? `Searching back to ${d.toLocaleDateString([], { month: "long", year: "numeric" })}…`
362 : "Searching…";
363 }
364 await searchIdx.ensureThread(tid, rows || []);
365 const q = (document.getElementById("chat-filter") || {}).value || "";
366 if (q.trim().length >= 2) renderSearchResults(q.trim());
367 },
368 });
369 }
370 } finally {
371 searchBackfill = null;
372 const q = (document.getElementById("chat-filter") || {}).value || "";
373 if (q.trim().length >= 2) renderSearchResults(q.trim());
374 }
375}
376
377// One history page for the backfill walk (before=0 ⇒ the most recent page).
378export async function fetchMessagePage(threadId, before) {
379 const q = before ? `&before=${before}&limit=200` : "&limit=200";
380 const r = await (
381 await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(threadId)}${q}`)
382 ).json();
383 return r.messages || [];
384}
385
386// Open the hit's thread and land on the message: page history back (bounded,
387// DOM-guided — the oldest rendered bubble's data-id is the cursor) until the
388// sid is in the feed, then reuse the reply-jump's scroll + flash.
389export async function jumpToMessage(tid, sid) {
390 await openThreadFn(tid);
391 const pane = document.getElementById("app-pane");
392 let feed = pane && pane.querySelector(".chatapp-feed");
393 // Landing in a thread that wasn't open: the fresh mount paints
394 // asynchronously (store hydrate → first render), so wait — bounded — for
395 // the feed and its rows to exist before deciding history needs paging;
396 // otherwise the walk bails on an empty feed and the jump lands nowhere.
397 for (let i = 0; i < 40 && !(feed && feed.querySelector(".msg[data-id]")); i++) {
398 await new Promise((r) => setTimeout(r, 50));
399 feed = pane && pane.querySelector(".chatapp-feed");
400 }
401 if (!feed) return;
402 for (let i = 0; i < 12 && !feed.querySelector(`.msg[data-id="${sid}"]`); i++) {
403 const first = feed.querySelector(".msg[data-id]");
404 const oldest = first ? +first.dataset.id : 0;
405 if (!oldest || oldest <= sid) break;
406 if (!(await chatAppLoadOlder(tid, oldest))) break;
407 }
408 scrollToMessage(feed, sid);
409 // Move focus with the navigation (the result button may now be hidden —
410 // mobile swaps the sidebar for the thread — and focus must never strand on
411 // an offscreen control). The bubble itself takes it; a deleted/never-found
412 // target falls back to the composer. Looked up across the whole pane, not
413 // just the feed: a reply made in a thread has no transcript bubble —
414 // scrollToMessage opened its thread view, and the bubble is in there.
415 const target = pane.querySelector(`.msg[data-id="${sid}"]`);
416 const focusEl = target || document.querySelector(".chatapp-root .composer .composer-input");
417 if (focusEl) {
418 if (target) target.tabIndex = -1;
419 focusEl.focus({ preventScroll: true });
420 }
421 if (target) {
422 // The found message waves from the crowd (.search-wave: a lift + tilt on
423 // the bubble over .flash's row tint; reduced motion gets a fading dashed
424 // ring). Remove-reflow-add restarts it when the same message is found
425 // again; the name check keeps .flash's earlier end from cutting it short.
426 target.classList.remove("search-wave");
427 void target.offsetWidth;
428 target.classList.add("search-wave");
429 target.addEventListener("animationend", function unwave(ev) {
430 if (ev.animationName !== "search-wave" && ev.animationName !== "search-found") return;
431 target.classList.remove("search-wave");
432 target.removeEventListener("animationend", unwave);
433 });
434 }
435}
436