eleven / ocho Public

web: extract message search into searchui.js (#944)

Verbatim move of the #586 search section — index wiring, results list,
deep back-fill, jump-to-message — with threadName joining shellstate
(const map, property-writes only: zero call-site changes). Navigation
into a result's thread is the injected onSearchOpen hook; searchIdx is
exported for the two live maintenance calls (message delete, thread
leave). The boot smoke caught a stowaway in the verbatim span — the
apps framework's setEphemeralTransport registration, unbannered at the
section's end — which stays in shell.js with the ws/session state it
rides on.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 14daa2784186c8683787957f1e5a8397cf3b1b11 parent fcf9956
4 files changed, +271 −249
  • CLAUDE.md +3 −0
  • web/static/searchui.js +248 −0
  • web/static/shell.js +18 −249
  • web/static/shellstate.js +2 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 218da63..a78424d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -189,6 +189,9 @@ Frontend module layout:
- `moderation.js` — muted people, report-to-admins, blocked people (#578):
sealed prefs under the member's own keypair; repaints via the
`onModerationChange` sink.
+- `searchui.js` — message search (#586): index wiring over the local store,
+ results, the deep back-fill walk, jump-to-message. Navigation via the
+ `onSearchOpen` sink; `search.js` stays the pure index.
- `store.js` — the local message store (#457): IndexedDB cache of RAW server
responses (ciphertext rows + wrapped keys, never plaintext) that boot and
thread-opens hydrate from before reconciling over the network. Its pure
diff --git a/web/static/searchui.js b/web/static/searchui.js
new file mode 100644
index 0000000..7041156
--- /dev/null
+++ b/web/static/searchui.js
@@ -0,0 +1,248 @@
+// 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 } 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";
+
+let openThreadFn = () => {};
+export function onSearchOpen(fn) {
+ openThreadFn = fn;
+}
+
+// ---- 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.
+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 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;
+}
+
+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++;
+ 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 openThreadFn(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 });
+ }
+}
diff --git a/web/static/shell.js b/web/static/shell.js
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
diff --git a/web/static/shellstate.js b/web/static/shellstate.js
index cdaeee6..0a39378 100644
--- a/web/static/shellstate.js
+++ b/web/static/shellstate.js
@@ -14,6 +14,7 @@ export let threadKeys = {}; // thread id -> CryptoKey
export let activeId = null; // the open thread's id, or null
export let ws = null; // the live WebSocket, or null between connects
export let nicknames = {}; // uid -> the name I call them (per-viewer names, #577)
+export let threadName = {}; // thread id -> decrypted display name
export function setSession(v) { session = v; }
export function setRoster(v) { roster = v; }
@@ -23,3 +24,4 @@ export function setThreadKeys(v) { threadKeys = v; }
export function setActiveId(v) { activeId = v; }
export function setWs(v) { ws = v; }
export function setNicknames(v) { nicknames = v; }
+export function setThreadName(v) { threadName = v; }