// Message search (#586) — the search box's index wiring, result list, deep // back-fill walk, and jump-to-message. The SearchIndex itself lives in // search.js; the local store feeds it. Extracted verbatim from shell.js — // state via shellstate.js; navigation to a result's thread is the injected // openThread hook (the one shell behaviour this needs). import { SearchIndex, snippetFor } from "/static/search.js"; import { allMessages as storeAllMessages, getBackfillMark as storeBackfillMark, runBackfill as storeRunBackfill } from "/static/store.js"; import { decrypt } from "/static/crypto.js"; import { scrollToMessage } from "/static/apps/chat.js"; import { session, threads, threadKeys, myUserId, threadName } from "/static/shellstate.js"; import { relTime } from "/static/format.js"; // The shell behaviours search rides: navigation into a result's thread, the // chat-thread predicate, scroll-back paging for the deep walk, and the chat // list's avatar painter (so Conversations rows wear the same faces). let openThreadFn = () => {}; let chatAppFor = () => null; let chatAppLoadOlder = async () => {}; let threadAvatarFn = () => document.createElement("span"); export function wireSearch(deps) { ({ openThread: openThreadFn, chatAppFor, chatAppLoadOlder, threadAvatar: threadAvatarFn } = deps); } // ---- message search (#586) ------------------------------------------------- // Search over the local store: rows decrypt lazily into an in-MEMORY index // (search.js — never written anywhere), then queries are instant scans. // Everything is local; the query never leaves the device. Results render in // a dropdown under the search box (#msg-results); a row jumps // to the message in its thread. "Search earlier messages" walks incomplete // threads' full history into the store (runBackfill), streaming new hits in, // cancellable — the #228 time machine. export const searchIdx = new SearchIndex(async (row, tid) => { try { const data = await decrypt(threadKeys[tid], row.blob); // Only plain chat prose is searchable — typed app events and reactions // have no text a member would hunt for. if (data.type || !data.text) return null; return { text: data.text, name: data.name || "", ts: data.ts || row.created_at * 1000 }; } catch { return null; } }); // The threads search covers: every chat-feed thread we hold a key for. function searchableThreads() { return threads.filter((t) => chatAppFor(t.id) && threadKeys[t.id]); } let searchSeq = 0; let searchTimer = null; let searchBackfill = null; // { controller } while the deep walk runs let searchCursorKey = null; // "tid:sid" of the keyboard-highlighted row; null → the top hit export function scheduleMessageSearch(query) { clearTimeout(searchTimer); const box = document.getElementById("msg-results"); if (!box) return; if (!query || query.length < 2) { // Leaving search cancels any deep walk — its watermark persists, so a // later "Search earlier messages" resumes where it stopped. if (searchBackfill) searchBackfill.controller.abort(); searchSeq++; searchCursorKey = null; box.classList.add("hidden"); box.textContent = ""; const filter = document.getElementById("chat-filter"); if (filter) { filter.setAttribute("aria-expanded", "false"); filter.removeAttribute("aria-activedescendant"); } return; } searchCursorKey = null; // an edited query re-highlights the top hit searchTimer = setTimeout(() => runMessageSearch(query), 250); } // Ending the search goes through the real input path — value cleared plus an // input event — so the name-filtered chat list resets in the same breath // (shell.js owns that wiring) and scheduleMessageSearch hides the dropdown. function clearSearch(refocus) { const filter = document.getElementById("chat-filter"); if (!filter) return; filter.value = ""; filter.dispatchEvent(new Event("input", { bubbles: true })); if (refocus) filter.focus(); } // Scaffold rebuilds can remove the node holding focus; when that happens // (focus fell to ) hand it back to the search box rather than stranding // a keyboard user at the top of the document. function rescueFocus() { if (document.activeElement !== document.body) return; const filter = document.getElementById("chat-filter"); if (filter) filter.focus(); } // The dropdown dismisses like the filter menu beside it: a click anywhere // outside the search bar and the results ends the search. (A pick ends it // too — the row's own onclick — before this listener sees the click.) document.addEventListener("click", (e) => { const box = document.getElementById("msg-results"); if (!box || box.classList.contains("hidden")) return; const bar = document.getElementById("chat-search"); if (box.contains(e.target) || (bar && bar.contains(e.target))) return; clearSearch(false); }); // Every navigable row in the dropdown — Conversations and Messages alike — // in visual order (section headers are presentational, never cursor stops). function optionRows(s) { return [...s.list.querySelectorAll('[role="option"]')]; } // The keyboard cursor: focus never leaves the search box — the highlighted // row (top hit by default, so plain Enter opens it with no arrowing) is a // virtual cursor the box's aria-activedescendant tracks, combobox-style. function setCursor(s, row) { for (const r of optionRows(s)) { r.classList.toggle("active", r === row); r.setAttribute("aria-selected", String(r === row)); } const filter = document.getElementById("chat-filter"); if (filter) { if (row) filter.setAttribute("aria-activedescendant", row.id); else filter.removeAttribute("aria-activedescendant"); } searchCursorKey = row ? row.dataset.key : null; } // Keyboard: Down/Up move the highlight from the search box (or a Tab-focused // row), Enter in the box opens the highlighted hit, Escape ends the search // from anywhere in it. Rows are real buttons, so Tab + Enter/Space on a row // already behave. document.addEventListener("keydown", (e) => { if (e.isComposing) return; // IME owns Enter/arrows mid-composition const box = document.getElementById("msg-results"); if (!box || box.classList.contains("hidden")) return; const filter = document.getElementById("chat-filter"); if (e.target !== filter && !box.contains(e.target)) return; if (e.key === "Escape") { e.preventDefault(); clearSearch(true); return; } const s = resultsScaffold(); if (!s) return; const rows = optionRows(s); const cur = rows.findIndex((r) => r.classList.contains("active")); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); if (!rows.length) return; const next = e.key === "ArrowDown" ? Math.min(cur + 1, rows.length - 1) : Math.max(cur - 1, 0); setCursor(s, rows[next]); rows[next].scrollIntoView({ block: "nearest" }); return; } if (e.key === "Enter" && e.target === filter && cur >= 0) { e.preventDefault(); rows[cur].click(); } }); async function runMessageSearch(query) { const mySeq = ++searchSeq; // Conversations (and any already-indexed hits) paint before the first // decrypt — a name search answers instantly, whatever the index is up to. renderSearchResults(query); // Stream: render after each thread's ensure, so a big space's first search // shows hits as they decrypt instead of a long blank box. for (const t of searchableThreads()) { await searchIdx.ensureThread(t.id, await storeAllMessages(t.id)); if (mySeq !== searchSeq) return; // superseded while decrypting renderSearchResults(query); } } // The results scaffold persists across renders: ONE aria-live status node // whose text mutates (a recreated live region isn't reliably announced, and // two would double-speak), a list rebuilt per render, and a single footer // slot (the deeper-search affordance / the walk's Stop control — the latter // outside the live region so it isn't re-announced). function resultsScaffold() { const box = document.getElementById("msg-results"); if (!box) return null; if (!box.firstChild) { const status = document.createElement("div"); status.className = "msg-results-count"; status.setAttribute("aria-live", "polite"); const list = document.createElement("div"); list.id = "msg-results-list"; // the search box's aria-controls target list.setAttribute("role", "listbox"); list.setAttribute("aria-label", "Search results"); const footer = document.createElement("div"); box.append(status, list, footer); } const [status, list, footer] = box.children; return { box, status, list, footer }; } // Conversations whose name matches the query — the same predicate the chat // list filters by, so the dropdown finds exactly what the (covered) list // would show, closed chats included. Most recent first, capped so message // hits keep the room. function conversationHits(query) { const q = query.toLowerCase(); return threads .filter((t) => (threadName[t.id] || "").toLowerCase().includes(q)) .sort((a, b) => (b.last_at || b.created_at || 0) - (a.last_at || a.created_at || 0)) .slice(0, 8); } // A muted section header inside the listbox — visual structure only, so the // keyboard cursor and aria-activedescendant skip straight over it. function sectionHeader(text) { const h = document.createElement("div"); h.className = "msg-results-count"; h.setAttribute("role", "presentation"); h.textContent = text; return h; } function renderSearchResults(query) { const s = resultsScaffold(); if (!s) return; const convs = conversationHits(query); const hits = searchIdx.query(query); s.box.classList.remove("hidden"); // One live region, one voice: the summary covers both sections (and the // deep walk's narration below). const msgPart = hits.length ? `${hits.length}${hits.length === 50 ? "+" : ""} message${hits.length === 1 ? "" : "s"}` : "No messages found"; let statusText = convs.length ? `${convs.length} conversation${convs.length === 1 ? "" : "s"} · ${msgPart}` : hits.length ? msgPart : "No matches found"; if (searchBackfill) statusText += ` · ${searchBackfill.progress}`; if (s.status.textContent !== statusText) s.status.textContent = statusText; s.list.textContent = ""; // ---- Conversations: matching chats, straight to the thread --------------- // The consolidated dropdown covers the chat list it floats over — so the // chats a name-search finds must be clickable HERE, not under it. if (convs.length) { s.list.appendChild(sectionHeader("Conversations")); for (const t of convs) { const row = document.createElement("button"); row.type = "button"; row.className = "msg-result conv-result"; row.appendChild(threadAvatarFn(t)); const name = document.createElement("span"); name.className = "conv-result-name"; name.textContent = threadName[t.id] || "Chat"; row.appendChild(name); row.dataset.key = `t:${t.id}`; row.id = `conv-hit-${t.id}`; // aria-activedescendant target row.setAttribute("role", "option"); row.onclick = () => { clearSearch(false); openThreadFn(t.id); }; s.list.appendChild(row); } if (hits.length) s.list.appendChild(sectionHeader("Messages")); } for (const h of hits) { const row = document.createElement("button"); row.type = "button"; row.className = "msg-result"; const top = document.createElement("div"); top.className = "msg-result-top"; const who = document.createElement("span"); who.className = "msg-result-who"; who.textContent = h.name || "Someone"; const where = document.createElement("span"); where.className = "msg-result-where"; where.textContent = `${threadName[h.tid] || "Chat"} · ${relTime(Math.floor(h.ts / 1000))}`; top.append(who, where); const snip = document.createElement("div"); snip.className = "msg-result-snippet"; snip.textContent = snippetFor(h.text, query); row.append(top, snip); row.dataset.key = `${h.tid}:${h.id}`; // the cursor follows a hit across streamed rebuilds row.id = `msg-hit-${h.tid}-${h.id}`; // aria-activedescendant target row.setAttribute("role", "option"); row.onclick = () => { // A pick ends the search — dropdown closed, chat list unfiltered — // then lands on the message; scrollToMessage's flash marks the spot // (no sticky highlight to outlive a search that's already over). clearSearch(false); jumpToMessage(h.tid, h.id); }; s.list.appendChild(row); } // The cursor lands on the top hit unless the member had arrowed to a hit // that's still in the (streamed re-)rendered list. const rows = optionRows(s); const kept = rows.find((r) => r.dataset.key === searchCursorKey); setCursor(s, kept || rows[0] || null); const filter = document.getElementById("chat-filter"); if (filter) filter.setAttribute("aria-expanded", "true"); renderSearchFooter(query); } // The footer slot: "Search earlier messages…" while any thread's history is // partial, or the running walk's Stop control. Guarded by searchSeq so a slow // mark read can't append a stale button over a newer render's footer. async function renderSearchFooter(query) { const s = resultsScaffold(); if (!s) return; if (searchBackfill) { s.footer.replaceChildren(searchBackfill.cancel); rescueFocus(); return; } const mySeq = searchSeq; const incomplete = []; for (const t of searchableThreads()) { const mark = await storeBackfillMark(t.id); if (!mark || !mark.complete) incomplete.push(t.id); } if (mySeq !== searchSeq || searchBackfill) return; // superseded meanwhile const hadFocus = s.footer.contains(document.activeElement); s.footer.textContent = ""; if (!incomplete.length) { rescueFocus(); return; } const btn = document.createElement("button"); btn.type = "button"; btn.className = "msg-result-deeper"; btn.textContent = "Search earlier messages…"; btn.onclick = () => startDeepSearch(query, incomplete); s.footer.replaceChildren(btn); if (hadFocus) btn.focus(); // the affordance keeps focus across its own rebuild } async function startDeepSearch(query, threadIds) { if (searchBackfill) return; // one walk at a time const controller = new AbortController(); const cancel = document.createElement("button"); cancel.type = "button"; cancel.className = "msg-result-cancel"; cancel.textContent = "Stop searching"; cancel.onclick = () => controller.abort(); searchBackfill = { controller, cancel, progress: "Searching…" }; renderSearchResults(query); // swaps the footer button for Stop (sync up to the footer's first await) cancel.focus(); // the deeper-button press continues on the walk's one control try { for (const tid of threadIds) { if (controller.signal.aborted) break; await storeRunBackfill(tid, (before) => fetchMessagePage(tid, before), { signal: controller.signal, onPage: async ({ oldestAt, rows }) => { // Narrate how deep we've dug; index ONLY this page's rows (the // seen-set dedupes) — re-reading the whole thread per page would // make a long walk O(pages²). if (searchBackfill) { const d = oldestAt ? new Date(oldestAt * 1000) : null; searchBackfill.progress = d ? `Searching back to ${d.toLocaleDateString([], { month: "long", year: "numeric" })}…` : "Searching…"; } await searchIdx.ensureThread(tid, rows || []); const q = (document.getElementById("chat-filter") || {}).value || ""; if (q.trim().length >= 2) renderSearchResults(q.trim()); }, }); } } finally { searchBackfill = null; const q = (document.getElementById("chat-filter") || {}).value || ""; if (q.trim().length >= 2) renderSearchResults(q.trim()); } } // One history page for the backfill walk (before=0 ⇒ the most recent page). async function fetchMessagePage(threadId, before) { const q = before ? `&before=${before}&limit=200` : "&limit=200"; const r = await ( await fetch(`/api/messages?token=${encodeURIComponent(session.token)}&thread=${encodeURIComponent(threadId)}${q}`) ).json(); return r.messages || []; } // Open the hit's thread and land on the message: page history back (bounded, // DOM-guided — the oldest rendered bubble's data-id is the cursor) until the // sid is in the feed, then reuse the reply-jump's scroll + flash. async function jumpToMessage(tid, sid) { await openThreadFn(tid); const pane = document.getElementById("app-pane"); let feed = pane && pane.querySelector(".chatapp-feed"); // Landing in a thread that wasn't open: the fresh mount paints // asynchronously (store hydrate → first render), so wait — bounded — for // the feed and its rows to exist before deciding history needs paging; // otherwise the walk bails on an empty feed and the jump lands nowhere. for (let i = 0; i < 40 && !(feed && feed.querySelector(".msg[data-id]")); i++) { await new Promise((r) => setTimeout(r, 50)); feed = pane && pane.querySelector(".chatapp-feed"); } if (!feed) return; for (let i = 0; i < 12 && !feed.querySelector(`.msg[data-id="${sid}"]`); i++) { const first = feed.querySelector(".msg[data-id]"); const oldest = first ? +first.dataset.id : 0; if (!oldest || oldest <= sid) break; if (!(await chatAppLoadOlder(tid, oldest))) break; } scrollToMessage(feed, sid); // Move focus with the navigation (the result button may now be hidden — // mobile swaps the sidebar for the thread — and focus must never strand on // an offscreen control). The bubble itself takes it; a deleted/never-found // target falls back to the composer. const target = feed.querySelector(`.msg[data-id="${sid}"]`); const focusEl = target || document.querySelector(".chatapp-root .composer .composer-input"); if (focusEl) { if (target) target.tabIndex = -1; focusEl.focus({ preventScroll: true }); } if (target) { // The found message waves from the crowd (.search-wave: a lift + tilt on // the bubble over .flash's row tint; reduced motion gets a fading dashed // ring). Remove-reflow-add restarts it when the same message is found // again; the name check keeps .flash's earlier end from cutting it short. target.classList.remove("search-wave"); void target.offsetWidth; target.classList.add("search-wave"); target.addEventListener("animationend", function unwave(ev) { if (ev.animationName !== "search-wave" && ev.animationName !== "search-found") return; target.classList.remove("search-wave"); target.removeEventListener("animationend", unwave); }); } }