| index f913cff..1c219a0 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -133,10 +133,10 @@ import "/static/apps/admin.js"; // the admin panel (admin-only; opened via /admi |
| import "/static/apps/appstudio.js"; // make + manage your own member apps (docs/member-apps.md) |
| import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-memory scratchpad (docs/eleven-memory.md) |
| import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage } from "/static/apps/chat.js"; |
| -import { SearchIndex, snippetFor } from "/static/search.js"; // message search over the local store (#586) |
| +import { scheduleMessageSearch, onSearchOpen, searchIdx } from "/static/searchui.js"; // message search over the local store (#586) |
| import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md) |
| import { THEME_META, THEME_ORDER, currentTheme, currentAppearance, setTheme, setAppearance, currentTextSize, setTextSize, onThemeApplied } from "/static/theme.js"; // applies the saved theme/text-size on import |
| -import { session, roster, myUserId, threads, threadKeys, activeId, ws, setSession, setRoster, setMyUserId, setThreads, setActiveId, setWs, nicknames, setNicknames } from "/static/shellstate.js"; // the shared shell state (docs/shell-modules.md) |
| +import { session, roster, myUserId, threads, threadKeys, activeId, ws, setSession, setRoster, setMyUserId, setThreads, setActiveId, setWs, nicknames, setNicknames, threadName } from "/static/shellstate.js"; // the shared shell state (docs/shell-modules.md) |
| |
| import { openLightbox } from "/static/lightbox.js"; |
| import { mutedPeople, blockedPeople, loadModPrefs, setPersonMuted, openReportSheet, loadBlocks, setPersonBlocked, onModerationChange } from "/static/moderation.js"; |
| @@ -177,6 +177,22 @@ import { |
| import { anyComposerHasText, formatAsYouTypePref, setFormatAsYouType, classicComposerPref, setClassicComposer } from "/static/composer.js"; |
| |
| onThemeApplied(pushTheme); // live-retint mounted sandbox frames on theme change |
| +onSearchOpen(openThread); // a tapped search result navigates to its thread |
| + |
| +// Ephemeral transport (ctx.emitEphemeral): same encryption as emit, but the |
| +// envelope's `ephemeral` flag asks the server to relay-and-forget — no store, |
| +// no push, no message id. Presence traffic only. |
| +setEphemeralTransport((threadId, event) => { |
| + const key = threadKeys[threadId]; |
| + if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(false); |
| + const payload = { ...event, name: session.name, ts: Date.now() }; |
| + if (myUserId) payload.uid = myUserId; |
| + return encrypt(key, payload).then((blob) => { |
| + ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob, ephemeral: true })); |
| + return true; |
| + }); |
| +}); |
| + |
| // A mute folds bubbles in the open thread; a block also re-badges the list. |
| onModerationChange((threadsToo) => { |
| renderActiveSurfaces(); |
| @@ -835,7 +851,6 @@ window.__lchatMenuPick = (token, id) => { |
| |
| // App state. |
| const threadKeyB64 = {}; // thread id -> raw key (base64url), to wrap for new members |
| -const threadName = {}; // thread id -> display name |
| const threadApp = {}; // thread id -> mini-app name, for a kind=="self" app-thread |
| // A thread's chosen VIEW. "chat" (a DM-style conversation) is always the default |
| // view; an app-thread also offers the "interface" view (its interactive UI). More |
| @@ -4828,252 +4843,6 @@ async function chatAppLoadOlder(threadId, beforeSid) { |
| return msgs.length; |
| } |
| |
| -// ---- 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 |
| -// the sidebar (#msg-results) below the name-filtered chat list; 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. |
| -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 searchActiveTarget = null; // .msg element carrying the sticky .search-active highlight |
| -let searchActiveHitKey = null; // "tid:sid" of the currently jumped-to result, for row styling |
| - |
| -// Sticky highlight is separate from .flash (which always fades on its own): |
| -// it stays on the jumped-to message until another result is picked or the |
| -// search ends, at which point this clears it. |
| -function clearSearchHighlight() { |
| - if (searchActiveTarget) searchActiveTarget.classList.remove("search-active"); |
| - searchActiveTarget = null; |
| - searchActiveHitKey = null; |
| -} |
| - |
| -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++; |
| - box.classList.add("hidden"); |
| - box.textContent = ""; |
| - clearSearchHighlight(); |
| - return; |
| - } |
| - searchTimer = setTimeout(() => runMessageSearch(query), 250); |
| -} |
| - |
| -async function runMessageSearch(query) { |
| - const mySeq = ++searchSeq; |
| - // 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"); |
| - const footer = document.createElement("div"); |
| - box.append(status, list, footer); |
| - } |
| - const [status, list, footer] = box.children; |
| - return { box, status, list, footer }; |
| -} |
| - |
| -function renderSearchResults(query) { |
| - const s = resultsScaffold(); |
| - if (!s) return; |
| - const hits = searchIdx.query(query); |
| - s.box.classList.remove("hidden"); |
| - let statusText = hits.length |
| - ? `${hits.length}${hits.length === 50 ? "+" : ""} message${hits.length === 1 ? "" : "s"}` |
| - : "No messages found"; |
| - // The deep walk's narration shares the single live region — one voice. |
| - if (searchBackfill) statusText += ` · ${searchBackfill.progress}`; |
| - if (s.status.textContent !== statusText) s.status.textContent = statusText; |
| - s.list.textContent = ""; |
| - 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); |
| - const key = `${h.tid}:${h.id}`; |
| - if (key === searchActiveHitKey) row.classList.add("active"); |
| - row.onclick = () => { |
| - searchActiveHitKey = key; |
| - for (const r of s.list.children) r.classList.toggle("active", r === row); |
| - jumpToMessage(h.tid, h.id); |
| - }; |
| - s.list.appendChild(row); |
| - } |
| - 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); |
| - 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 |
| - s.footer.textContent = ""; |
| - if (!incomplete.length) 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); |
| -} |
| - |
| -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 |
| - 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 openThread(tid); |
| - const pane = document.getElementById("app-pane"); |
| - const 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}"]`); |
| - if (searchActiveTarget && searchActiveTarget !== target) searchActiveTarget.classList.remove("search-active"); |
| - searchActiveTarget = target; |
| - if (target) target.classList.add("search-active"); |
| - const focusEl = target || document.querySelector(".chatapp-root .composer .composer-input"); |
| - if (focusEl) { |
| - if (target) target.tabIndex = -1; |
| - focusEl.focus({ preventScroll: true }); |
| - } |
| -} |
| - |
| -// Ephemeral transport (ctx.emitEphemeral): same encryption as emit, but the |
| -// envelope's `ephemeral` flag asks the server to relay-and-forget — no store, |
| -// no push, no message id. Presence traffic only. |
| -setEphemeralTransport((threadId, event) => { |
| - const key = threadKeys[threadId]; |
| - if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve(false); |
| - const payload = { ...event, name: session.name, ts: Date.now() }; |
| - if (myUserId) payload.uid = myUserId; |
| - return encrypt(key, payload).then((blob) => { |
| - ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob, ephemeral: true })); |
| - return true; |
| - }); |
| -}); |
| |
| // ---- app state snapshots (thread_state) ---------------------------------- |
| // The fold source is the encrypted event log, but the client only holds the |