| 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). |
| 6 | import { SearchIndex, snippetFor } from "/static/search.js"; |
| 7 | import { allMessages as storeAllMessages, getBackfillMark as storeBackfillMark, runBackfill as storeRunBackfill } from "/static/store.js"; |
| 8 | import { decrypt } from "/static/crypto.js"; |
| 9 | import { scrollToMessage } from "/static/apps/chat.js"; |
| 10 | import { session, threads, threadKeys, myUserId, threadName } from "/static/shellstate.js"; |
| 11 | import { 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). |
| 16 | let openThreadFn = () => {}; |
| 17 | let chatAppFor = () => null; |
| 18 | let chatAppLoadOlder = async () => {}; |
| 19 | let threadAvatarFn = () => document.createElement("span"); |
| 20 | export 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. |
| 32 | export 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. |
| 45 | function searchableThreads() { |
| 46 | return threads.filter((t) => chatAppFor(t.id) && threadKeys[t.id]); |
| 47 | } |
| 48 | |
| 49 | let searchSeq = 0; |
| 50 | let searchTimer = null; |
| 51 | let searchBackfill = null; // { controller } while the deep walk runs |
| 52 | let searchCursorKey = null; // "tid:sid" of the keyboard-highlighted row; null → the top hit |
| 53 | |
| 54 | export 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. |
| 80 | function 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. |
| 91 | function 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.) |
| 100 | document.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). |
| 110 | function 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. |
| 117 | function 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. |
| 134 | document.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 | |
| 163 | async 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). |
| 182 | function 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. |
| 204 | function 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. |
| 214 | function 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 | |
| 222 | function 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. |
| 308 | async 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 | |
| 338 | async 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). |
| 378 | 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. |
| 389 | 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. |
| 413 | const target = feed.querySelector(`.msg[data-id="${sid}"]`); |
| 414 | const focusEl = target || document.querySelector(".chatapp-root .composer .composer-input"); |
| 415 | if (focusEl) { |
| 416 | if (target) target.tabIndex = -1; |
| 417 | focusEl.focus({ preventScroll: true }); |
| 418 | } |
| 419 | if (target) { |
| 420 | // The found message waves from the crowd (.search-wave: a lift + tilt on |
| 421 | // the bubble over .flash's row tint; reduced motion gets a fading dashed |
| 422 | // ring). Remove-reflow-add restarts it when the same message is found |
| 423 | // again; the name check keeps .flash's earlier end from cutting it short. |
| 424 | target.classList.remove("search-wave"); |
| 425 | void target.offsetWidth; |
| 426 | target.classList.add("search-wave"); |
| 427 | target.addEventListener("animationend", function unwave(ev) { |
| 428 | if (ev.animationName !== "search-wave" && ev.animationName !== "search-found") return; |
| 429 | target.classList.remove("search-wave"); |
| 430 | target.removeEventListener("animationend", unwave); |
| 431 | }); |
| 432 | } |
| 433 | } |
| 434 | |