eleven / ocho Public

web: shellstate.js — the shared-state seam (docs/shell-modules.md) (#941)

The seven core bindings move to shellstate.js as export let +
assignment-only setters. All ~700 reads and every property mutation keep
working verbatim through ES-module live bindings; the 14 whole-binding
assignment sites become setter calls. Verified beyond the unit tests
with a headless end-to-end boot smoke (sign-in → shell boot → open
thread → send a message → rendered), since a missed write would only
fail at runtime.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell c2f4d10568290acf2b387857796dd34d5ae75872 parent 19c5a31
3 files changed, +42 −21
  • CLAUDE.md +3 −0
  • web/static/shell.js +16 −21
  • web/static/shellstate.js +23 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 20afc30..61efba0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -183,6 +183,9 @@ Frontend module layout:
- `media.js` — pure client-side media helpers: `processImage` (downscale +
re-encode + EXIF strip + BlurHash) and `videoMeta` (poster frame, dims,
duration). Bytes out; callers encrypt.
+- `shellstate.js` — the shell's shared mutable state (session, threads,
+ threadKeys, activeId, ws, roster, myUserId): `export let` + assignment-only
+ setters. Design: `docs/shell-modules.md`. State only, never behaviour.
- `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/shell.js b/web/static/shell.js
index 27193fe..f7af543 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -136,6 +136,7 @@ import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendN
import { SearchIndex, snippetFor } from "/static/search.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 } from "/static/shellstate.js"; // the shared shell state (docs/shell-modules.md)
import { openLightbox } from "/static/lightbox.js";
import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
@@ -775,7 +776,7 @@ const app = document.getElementById("app");
// never handed to the webview: native unwraps the thread key and passes only it,
// so a single thread's page can't reach any other thread's content.
const embed = window.__lchatEmbed || null;
-let session = embed ? { token: embed.token, name: embed.name } : loadSession();
+setSession(embed ? { token: embed.token, name: embed.name } : loadSession());
// Post a message back to the native shell's "lchat" handler (no-op in a browser).
function nativePost(msg) {
@@ -827,10 +828,6 @@ window.__lchatMenuPick = (token, id) => {
};
// App state.
-let roster = []; // [{user_id, label, public_key}]
-let myUserId = "";
-let threads = []; // server Thread objects (id, kind, name_blob, members, wrapped)
-const threadKeys = {}; // thread id -> CryptoKey
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
@@ -863,9 +860,7 @@ let chatFilter = ""; // lowercased substring filtering the thread list (search b
// The funnel menu beside search: online-only plus category checkboxes. All
// categories on + online off = the unfiltered default (funnel unhighlighted).
const chatFilters = { online: false, people: true, bots: true, apps: true };
-let activeId = null;
let pendingJoinThread = null; // the DM a fresh invite created — the welcome conversation offers it at the end
-let ws = null;
let everConnected = false; // has the socket ever opened? (distinguishes reconnect from first connect)
let wsConnected = false; // is the socket open right now? (gates the send button while reconnecting)
let pingTimer = null; // keepalive ping interval while the socket is open
@@ -885,7 +880,7 @@ const CUSTOM_APPS_TIMEOUT_MS = 4000;
// Otherwise route straight away — the extra tick only happens on a real return.
if (!embed && !session && location.hash.includes("lchat_restore=")) {
checkRestoreReturn().then((restored) => {
- if (restored) session = loadSession();
+ if (restored) setSession(loadSession());
route();
});
} else {
@@ -1185,7 +1180,7 @@ async function bootEmbed() {
// The native shell can land straight in a custom app's thread, so the
// embed path needs registration complete before its render too.
await loadCustomApps();
- myUserId = embed.myUserId || "";
+ setMyUserId(embed.myUserId || "");
setCurrentUser(myUserId, session.name || "New member");
await loadRoster(); // token-only; gives sender labels + profile photos
if (embed.instanceKey) {
@@ -1218,9 +1213,9 @@ async function bootEmbed() {
// has a key — the others can't be unwrapped here without the private key.
try {
const r = await (await fetch(`/api/threads?token=${encodeURIComponent(session.token)}`)).json();
- threads = r.threads || [];
+ setThreads(r.threads || []);
} catch {
- threads = [];
+ setThreads([]);
}
if (!threads.find((t) => t.id === tid)) threads.push({ id: tid, kind: "dm", members: [] });
threadName[tid] = await displayName(threads.find((t) => t.id === tid));
@@ -1445,12 +1440,12 @@ async function loadRoster() {
// The apply half of loadRoster — also run over the cached response on boot.
async function applyRoster(r) {
- roster = r.roster || [];
+ setRoster(r.roster || []);
// In embed mode the shell tells us our user id up front (it can't be matched by
// public key — the webview never holds one). Otherwise find ourselves by pubkey.
if (!myUserId) {
const me = roster.find((e) => e.public_key === session.userPub);
- myUserId = me ? me.user_id : "";
+ setMyUserId(me ? me.user_id : "");
}
// The "New member" fallback (here and on every outbound payload): setup and
// welcome claimers are signed in nameless until Eleven's first question — if
@@ -2516,7 +2511,7 @@ async function loadThreads() {
// also run over the cached response on boot, so hydration and the network
// path are one code path.
async function applyThreads(r) {
- threads = r.threads || [];
+ setThreads(r.threads || []);
for (const t of threads) {
if (!threadKeys[t.id] && (t.wrapped || t.open_key)) {
// Two key sources: the per-member wrapped copy, then — for open groups —
@@ -3883,7 +3878,7 @@ async function openThread(id) {
if (activeId) stashAppPane(activeId); // park the outgoing app-thread's full-pane root
const myOpen = ++openSeq;
- activeId = id;
+ setActiveId(id);
localStorage.setItem(ACTIVE_KEY, id); // remember where we are, to restore on refresh
unread[id] = false;
markThreadRead(id); // …and tell the server, so your other devices clear too
@@ -7331,7 +7326,7 @@ async function resyncActive() {
function connectWS() {
const proto = location.protocol === "https:" ? "wss://" : "ws://";
- ws = new WebSocket(`${proto}${location.host}/ws?token=${encodeURIComponent(session.token)}`);
+ setWs(new WebSocket(`${proto}${location.host}/ws?token=${encodeURIComponent(session.token)}`));
ws.onopen = () => {
// (Re)connected: hide the pill and (re)start the keepalive.
if (reconnectStatusTimer) {
@@ -8606,7 +8601,7 @@ async function closeChat() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: session.token, hidden: true }),
}).catch(() => {});
- activeId = null;
+ setActiveId(null);
localStorage.removeItem(ACTIVE_KEY); // don't reopen a closed chat on refresh
renderThreadList();
document.getElementById("thread-view").classList.add("hidden");
@@ -8635,7 +8630,7 @@ async function endConversation() {
}
dmClosed[id] = true;
hidden[id] = true;
- activeId = null;
+ setActiveId(null);
localStorage.removeItem(ACTIVE_KEY);
renderThreadList();
document.getElementById("thread-view").classList.add("hidden");
@@ -8875,12 +8870,12 @@ async function leaveGroup() {
} catch {
return alert("Couldn't leave the group. Try again.");
}
- threads = threads.filter((x) => x.id !== id);
+ setThreads(threads.filter((x) => x.id !== id));
delete threadKeys[id];
storeClearThread(id).catch(() => {}); // a left thread keeps no local rows (#457)
searchIdx.dropThread(id); // …and stops matching in search (#586)
if (activeId === id) {
- activeId = null;
+ setActiveId(null);
localStorage.removeItem(ACTIVE_KEY); // the thread we were in is gone
document.getElementById("thread-view").classList.add("hidden");
document.getElementById("thread-empty").classList.remove("hidden");
@@ -9974,7 +9969,7 @@ async function cancelInvitedChat(threadId) {
delete inviteLinks[threadId];
delete threadKeys[threadId];
delete threadKeyB64[threadId];
- activeId = null;
+ setActiveId(null);
await loadThreads();
renderThreadList();
document.getElementById("thread-view").classList.add("hidden");
diff --git a/web/static/shellstate.js b/web/static/shellstate.js
new file mode 100644
index 0000000..4e15a9e
--- /dev/null
+++ b/web/static/shellstate.js
@@ -0,0 +1,23 @@
+// The shell's shared mutable state — the seven bindings every part of the chat
+// page reads (docs/shell-modules.md § The shellstate seam). This module OWNS
+// them; everyone else imports them under these same names. ES-module live
+// bindings make reads and property mutations (threadKeys[id] = k) work
+// unchanged — only reassigning a WHOLE binding needs its setter, which is all
+// the setters are: assignment, no logic. Shared *behaviour* does not belong
+// here; helpers keep moving into their own modules.
+
+export let session = null; // { token, name } — set at boot (embed or localStorage)
+export let roster = []; // [{user_id, label, public_key}]
+export let myUserId = "";
+export let threads = []; // server Thread objects (id, kind, name_blob, members, wrapped)
+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 function setSession(v) { session = v; }
+export function setRoster(v) { roster = v; }
+export function setMyUserId(v) { myUserId = v; }
+export function setThreads(v) { threads = v; }
+export function setThreadKeys(v) { threadKeys = v; }
+export function setActiveId(v) { activeId = v; }
+export function setWs(v) { ws = v; }