eleven / messenger Public

web: the module reference test — and the full sweep it demanded (#978)

Paul's question ('are there no tests for this module?') was the right
one: the extracted shell modules had none, and three ReferenceErrors
reached the canary. test/module-refs.test.mjs now statically checks
every extracted module for identifiers it neither declares, imports,
nor gets from the globals — wired into npm test, failing CI on the
whole class.

Running it across all 13 modules found and fixed the full remaining
set: tiers/tierKeys/tierKeyB64, threadApp, pendingJoinThread, pinned,
muted, sealedBranding, ACTIVE_KEY, MAX_UPLOAD join shellstate (with
setters where reassigned); ws/instanceKey/ENABLE_* imports completed;
avatarURLs exported from avatars.js; searchIdx imported by
groupactions; moderation.js gains its verbs import (its block path
would have crashed — latent, unexercised until now). sealedBranding
was mis-registered as a verb — it's state; corrected.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell da70290ffea612cf060382620c4d264fa70a9291 parent 8b5f74b
9 files changed, +118 −30
  • package.json +1 −1
  • test/module-refs.test.mjs +83 −0
  • web/static/avatars.js +1 −1
  • web/static/groupactions.js +3 −2
  • web/static/moderation.js +5 −4
  • web/static/newchat.js +1 −1
  • web/static/onboardingui.js +1 −1
  • web/static/shell.js +10 −20
  • web/static/shellstate.js +13 −0
diff --git a/package.json b/package.json
index 25cd400..e2f7436 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
+ "test": "node --test test/module-refs.test.mjs && node test/crypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/status-contract.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs"
}
}
diff --git a/test/module-refs.test.mjs b/test/module-refs.test.mjs
new file mode 100644
index 0000000..3de53d7
--- /dev/null
+++ b/test/module-refs.test.mjs
@@ -0,0 +1,83 @@
+// module-refs.test.mjs — the static reference check for the extracted shell
+// modules (docs/shell-modules.md). A verbatim slice can silently drop an
+// import: reads work in the old module and throw ReferenceError in the new
+// one, at runtime only, on paths tests don't walk (the empty-new-chat-sheet
+// bug, 2026-07-15). This test fails on any identifier a module references
+// but neither declares, imports, nor gets from the JS/browser globals.
+//
+// The scan is deliberately scope-blind (a declaration anywhere in the file
+// counts) — that trades a known false-negative class (a same-named local
+// masking a missing import) for zero false positives from nesting. Keep the
+// GLOBALS list and per-file allowlist tight; every allowlist entry must say
+// why it's resolved at runtime.
+import { readFileSync } from "node:fs";
+import { test } from "node:test";
+import assert from "node:assert";
+
+const MODULES = [
+ "theme", "lightbox", "media", "shellstate", "shellverbs", "moderation",
+ "searchui", "avatars", "rosterui", "groupactions", "newchat", "adddevice",
+ "onboardingui",
+];
+
+const GLOBALS = new Set(`if else for while switch case default catch finally return await async new typeof delete void in of do break continue try throw this true false null undefined function const let var class extends super static get set yield export import from as
+fetch encodeURIComponent decodeURIComponent atob btoa JSON Promise Date Math Object Array String Number Boolean URL URLSearchParams Blob File Uint8Array ArrayBuffer FileReader Set Map WeakMap WeakSet RegExp Error TypeError RangeError console document window location history navigator crypto performance screen localStorage sessionStorage matchMedia requestAnimationFrame cancelAnimationFrame setTimeout setInterval clearTimeout clearInterval alert confirm prompt structuredClone AbortController CustomEvent Event KeyboardEvent MouseEvent WebSocket TextEncoder TextDecoder Intl isNaN isFinite parseInt parseFloat Function Symbol Proxy Reflect queueMicrotask createImageBitmap Audio Image Notification getComputedStyle getSelection NaN Infinity globalThis arguments addEventListener removeEventListener dispatchEvent`.split(/\s+/));
+
+// Verified-at-runtime exceptions, one line of why each:
+const ALLOW = {
+ // media.js: destructured/defaulted params inside multiline signatures the
+ // scanner's flat param regex doesn't walk; all local.
+ "media.js": ["ch", "dims", "dur", "frame", "poster", "kept", "ignore", "no", "undecodable"],
+};
+
+function scan(name) {
+ const src = readFileSync(new URL(`../web/static/${name}.js`, import.meta.url), "utf8");
+ let code = src.replace(/\/\/[^\n]*/g, "");
+ code = code.replace(/\/\*[\s\S]*?\*\//g, "");
+ code = code.replace(/`(?:[^`\\]|\\[\s\S])*`/g, "``");
+ code = code.replace(/"(?:[^"\\]|\\.)*"/g, '""');
+ code = code.replace(/'(?:[^'\\]|\\.)*'/g, "''");
+ code = code.replace(/\/(?:[^/\\\n[]|\\.|\[(?:[^\]\\]|\\.)*\])+\/[gimsuy]*/g, "0"); // regex literals
+
+ const declared = new Set();
+ for (const m of code.matchAll(/function\s+([A-Za-z_$][\w$]*)/g)) declared.add(m[1]);
+ for (const m of code.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g)) declared.add(m[1]);
+ for (const m of code.matchAll(/(?:const|let|var)\s*\{([^}]*)\}/g))
+ for (const w of m[1].replaceAll(":", ",").split(",")) if (w.trim()) declared.add(w.trim().split("=")[0].trim());
+ for (const m of code.matchAll(/(?:const|let|var)\s*\[([^\]]*)\]/g))
+ for (const w of m[1].split(",")) if (w.trim()) declared.add(w.trim().split("=")[0].trim());
+ for (const m of code.matchAll(/import\s*\{([^}]*)\}/g))
+ for (const w of m[1].split(",")) if (w.trim()) declared.add(w.trim().split(/\s+as\s+/).pop().trim());
+ for (const m of code.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from/g)) declared.add(m[1]);
+ for (const m of code.matchAll(/\(([^()]*)\)\s*(?:=>|\{)/g))
+ for (const w of m[1].split(",")) {
+ const bare = w.trim().split("=")[0].trim().replace(/^\.{3}/, "");
+ if (/^[A-Za-z_$][\w$]*$/.test(bare)) declared.add(bare);
+ // one level of destructured params: ({ a, b: c }) / ([x, y])
+ for (const inner of bare.replace(/^[{[]|[}\]]$/g, "").replaceAll(":", ",").split(","))
+ if (/^[A-Za-z_$][\w$]*$/.test(inner.trim())) declared.add(inner.trim());
+ }
+ for (const m of code.matchAll(/catch\s*\(([^)]*)\)/g)) declared.add(m[1].trim());
+ for (const m of code.matchAll(/([A-Za-z_$][\w$]*)\s*=>/g)) declared.add(m[1]); // single-param arrows
+
+ // multi-declarator lists: let a, b, c;
+ for (const m of code.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)+)\s*;/g))
+ for (const w of m[1].split(",")) declared.add(w.trim());
+
+ // usable references: strip import statements (declaration sites, incl. alias
+ // source names), then property accesses and object keys
+ code = code.replace(/import\s*\{[^}]*\}\s*from\s*""\s*;?/g, "");
+ let refs = code.replace(/\.\s*[A-Za-z_$][\w$]*/g, ".");
+ refs = refs.replace(/\b([A-Za-z_$][\w$]*)\s*:/g, ":");
+ const used = new Set([...refs.matchAll(/\b([A-Za-z_$][\w$]*)\b/g)].map((m) => m[1]));
+
+ const allow = new Set(ALLOW[`${name}.js`] || []);
+ return [...used].filter((id) => !declared.has(id) && !GLOBALS.has(id) && !allow.has(id)).sort();
+}
+
+for (const name of MODULES) {
+ test(`web/static/${name}.js has no unresolved references`, () => {
+ const unresolved = scan(name);
+ assert.deepStrictEqual(unresolved, [], `unresolved in ${name}.js: ${unresolved.join(", ")}`);
+ });
+}
diff --git a/web/static/avatars.js b/web/static/avatars.js
index f214a6f..787263c 100644
--- a/web/static/avatars.js
+++ b/web/static/avatars.js
@@ -6,7 +6,7 @@
import { decryptBytes } from "/static/crypto.js";
import { session, roster, threads, threadKeys, instanceKey } from "/static/shellstate.js";
-const avatarURLs = {}; // user id -> decrypted profile-photo object URL (cache)
+export const avatarURLs = {}; // user id -> decrypted profile-photo object URL (cache)
// A member's decrypted profile-photo object URL (instance key), or null.
export async function avatarFor(userId) {
diff --git a/web/static/groupactions.js b/web/static/groupactions.js
index 15717d2..7cb8725 100644
--- a/web/static/groupactions.js
+++ b/web/static/groupactions.js
@@ -4,10 +4,11 @@
// shell.js; core behaviours go through the verbs seam (docs/shell-modules.md),
// state through shellstate.js.
import { verbs } from "/static/shellverbs.js";
-import { session, roster, myUserId, threads, threadKeys, activeId, nicknames, threadName, instanceKey, threadKeyB64, unread, hidden, dmClosed, setNicknames, setActiveId, setThreads, embed } from "/static/shellstate.js";
+import { session, roster, myUserId, threads, threadKeys, activeId, nicknames, threadName, instanceKey, threadKeyB64, unread, hidden, dmClosed, setNicknames, setActiveId, setThreads, embed, ENABLE_BOTS, ACTIVE_KEY, MAX_UPLOAD, tiers, tierKeys, tierKeyB64, pinned, muted, sealedBranding } from "/static/shellstate.js";
import { wrapThreadKey, decrypt, encrypt, encryptBytes } from "/static/crypto.js";
import { processImage } from "/static/media.js";
-import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js";
+import { avatarFor, threadAvatarFor, paintAvatar, avatarEl, avatarURLs } from "/static/avatars.js";
+import { searchIdx } from "/static/searchui.js";
import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
import { emitAppEvent, groupCapableApps, appGlyphInto, groupAppsFor } from "/static/apps.js";
import { displayNameFor, presenceLabel, renameNotice } from "/static/ui.js";
diff --git a/web/static/moderation.js b/web/static/moderation.js
index 71f22be..8d84a5d 100644
--- a/web/static/moderation.js
+++ b/web/static/moderation.js
@@ -6,7 +6,8 @@
// thread / thread list after a change) is the injected `repaint` sink.
import { openSealed, sealForPub } from "/static/crypto.js";
import { displayNameFor } from "/static/ui.js";
-import { session, roster, myUserId, nicknames } from "/static/shellstate.js";
+import { session, roster, myUserId, nicknames, hidden, dmClosed, threadName } from "/static/shellstate.js";
+import { verbs } from "/static/shellverbs.js";
let repaint = () => {};
export function onModerationChange(fn) {
@@ -96,7 +97,7 @@ export function setPersonMuted(uid, on) {
else mutedPeople.delete(uid);
mutedOps.set(uid, on);
saveModPrefs();
- syncThreadKeysToSW(); // keep the SW's muted-people set current
+ verbs.syncThreadKeysToSW(); // keep the SW's muted-people set current
repaint(false); // fold/unfold their bubbles in the open thread
}
@@ -109,7 +110,7 @@ export function openReportSheet({ uid, threadId, message }) {
const e = roster.find((x) => x.user_id === uid);
if (!e) return;
const who = displayNameFor(nicknames[uid], e.label) || "this member";
- const backdrop = modal();
+ const backdrop = verbs.modal();
const card = backdrop.querySelector(".sheet");
const h = document.createElement("h2");
h.textContent = `Report ${who}`;
@@ -309,7 +310,7 @@ export async function setPersonBlocked(uid, on) {
if (on) {
blockedPeople.add(uid);
setPersonMuted(uid, true); // block implies you stop seeing them
- const dm = existingDM(uid);
+ const dm = verbs.existingDM(uid);
if (dm) {
dmClosed[dm.id] = true; // the server just closed it; mirror at once
hidden[dm.id] = true;
diff --git a/web/static/newchat.js b/web/static/newchat.js
index 5f6ae9f..024254e 100644
--- a/web/static/newchat.js
+++ b/web/static/newchat.js
@@ -3,7 +3,7 @@
// verbatim from shell.js; behaviours ride the verbs seam, state shellstate.
import { verbs } from "/static/shellverbs.js";
import { ICON_WRENCH } from "/static/icons.js";
-import { session, roster, myUserId, threads, threadKeys, activeId, nicknames, threadName, threadKeyB64, hidden, dmClosed, embed, setActiveId, ENABLE_BOTS, ENABLE_APPS } from "/static/shellstate.js";
+import { session, roster, myUserId, threads, threadKeys, activeId, nicknames, threadName, threadKeyB64, hidden, dmClosed, embed, setActiveId, ENABLE_BOTS, ENABLE_APPS, instanceKey, ws, tiers, tierKeys, tierKeyB64 } from "/static/shellstate.js";
import { avatarFor, avatarEl, paintAvatar } from "/static/avatars.js";
import { decrypt, encrypt, generateKey, importKey, wrapThreadKey } from "/static/crypto.js";
import { appGlyphInto, personalApps } from "/static/apps.js";
diff --git a/web/static/onboardingui.js b/web/static/onboardingui.js
index a12e246..5d13214 100644
--- a/web/static/onboardingui.js
+++ b/web/static/onboardingui.js
@@ -5,7 +5,7 @@
// recorded steps the conversation would have produced. Extracted verbatim
// from shell.js; behaviours ride the verbs seam.
import { verbs } from "/static/shellverbs.js";
-import { session, roster, myUserId, threads, threadKeys, activeId, threadName, embed } from "/static/shellstate.js";
+import { session, roster, myUserId, threads, threadKeys, activeId, threadName, embed, ws, instanceKey, threadApp, pendingJoinThread } from "/static/shellstate.js";
import { encrypt } from "/static/crypto.js";
import { appStateFor, setupBridge, spaceInfo, emitAppEvent } from "/static/apps.js";
import { appendNotice, setNoticeTyping, feedGlideToBottom } from "/static/apps/chat.js";
diff --git a/web/static/shell.js b/web/static/shell.js
index 9dd13df..2b9f377 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -144,7 +144,7 @@ import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell
import { scheduleMessageSearch, wireSearch, 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, threadName, instanceKey, instanceKeyB64, setInstanceKey, threadKeyB64, unread, hidden, dmClosed, embed, ENABLE_AI, ENABLE_BOTS, ENABLE_APPS } 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, instanceKey, instanceKeyB64, setInstanceKey, threadKeyB64, unread, hidden, dmClosed, embed, ENABLE_AI, ENABLE_BOTS, ENABLE_APPS, threadApp, tiers, setTiers, tierKeys, tierKeyB64, pendingJoinThread, setPendingJoinThread, ACTIVE_KEY, MAX_UPLOAD, pinned, muted, sealedBranding, setSealedBranding } 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";
@@ -221,9 +221,7 @@ onModerationChange((threadsToo) => {
});
const SESSION_KEY = "lchat_session";
-const ACTIVE_KEY = "lchat_active"; // last-open thread id, so a refresh lands back in it
const isCoarse = matchMedia("(pointer: coarse)").matches;
-const MAX_UPLOAD = 25 * 1024 * 1024; // matches the default upload cap (main.go)
// Video clips get a larger ceiling (Signal-style send-and-play). Matches the
// server's default LCHAT_MAX_UPLOAD_BYTES; a lower per-instance cap just means
// the server rejects the upload and the composer shows a "couldn't add" message.
@@ -572,7 +570,6 @@ const blobURLs = {}; // blob id -> decrypted object URL (cache)
// unwrap); every minted invite link carries it in the #fragment.
let brandingB = null; // the short branding key (lowercase base32)
let brandingSalt = ""; // PBKDF2 salt for deriving from B (not secret)
-let sealedBranding = null; // decrypted {name, description, admin_name, admin_email, avatar_alt, avatar_mime}
let serverBrandingSealed = false;
// bootstrapBranding: after keys are up, learn whether this instance is sealed
@@ -605,13 +602,13 @@ async function bootstrapBranding() {
if (!brandingB) return;
try {
const bKey = await deriveBrandingKey(brandingB, brandingSalt);
- sealedBranding = await decrypt(bKey, g.branding_blob);
+ setSealedBranding(await decrypt(bKey, g.branding_blob));
} catch {
// Stale cached B — an unseal→re-seal mints a fresh key, and a device
// still holding the old one would otherwise render generic forever (and
// an admin device couldn't edit or unseal). The instance-key copy always
// holds the CURRENT B: fall back to it and repair the cache.
- sealedBranding = null;
+ setSealedBranding(null);
if (g.branding_key_sealed && instanceKey) {
try {
const opened = await decrypt(instanceKey, g.branding_key_sealed);
@@ -621,10 +618,10 @@ async function bootstrapBranding() {
localStorage.setItem("lchat_branding_b", brandingB);
} catch {}
const bKey = await deriveBrandingKey(brandingB, brandingSalt);
- sealedBranding = await decrypt(bKey, g.branding_blob);
+ setSealedBranding(await decrypt(bKey, g.branding_blob));
}
} catch {
- sealedBranding = null; // still undecryptable — surfaces stay generic
+ setSealedBranding(null); // still undecryptable — surfaces stay generic
}
}
}
@@ -653,7 +650,7 @@ async function resealBranding(patch) {
body: JSON.stringify(body),
});
if (r.ok) {
- sealedBranding = next;
+ setSealedBranding(next);
if (next.name) document.title = next.name;
}
return r.ok ? "ok" : r.status === 400 ? "invalid" : "error";
@@ -749,7 +746,7 @@ async function sealBrandingNow() {
if (!r.ok) return r.status === 409 ? "ok" : "error";
brandingB = b;
brandingSalt = salt;
- sealedBranding = branding;
+ setSealedBranding(branding);
serverBrandingSealed = true;
try {
localStorage.setItem("lchat_branding_b", b);
@@ -779,7 +776,7 @@ async function unsealBrandingNow() {
});
if (!r.ok) return "error";
serverBrandingSealed = false;
- sealedBranding = null;
+ setSealedBranding(null);
brandingB = null;
try {
localStorage.removeItem("lchat_branding_b");
@@ -791,9 +788,6 @@ async function unsealBrandingNow() {
}
// Tiers — "access levels" in every UI surface: admin-curated member sets,
// each with its own key on the instance-key pattern (docs/community-and-tiers.md).
-let tiers = []; // /api/tiers rows, personalised to this user
-const tierKeys = {}; // tier id -> CryptoKey
-const tierKeyB64 = {}; // tier id -> raw base64url (kept to wrap for others)
// The fixed reaction set (mirrors allowedReactions in hub.go).
const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
@@ -867,7 +861,6 @@ window.__lchatMenuPick = (token, id) => {
};
// App state.
-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
// views (custom, per-thread) are planned — a view dropdown, not just this toggle.
@@ -888,13 +881,10 @@ const inviteLinks = {};
// seconds). Synced from the server so "read on one device" clears the dot on
// the others; advanced locally first so the UI never waits on the POST.
const readMarks = {};
-const pinned = {}; // thread id -> bool: pinned to the top of the list, synced from the server
-const muted = {}; // thread id -> bool: muted (no notifications), synced from the server
let chatFilter = ""; // lowercased substring filtering the thread list (search box)
// 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 pendingJoinThread = null; // the DM a fresh invite created — the welcome conversation offers it at the end
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
@@ -1882,7 +1872,7 @@ async function loadTiers() {
// a cached-snapshot hydrate only unwraps keys; the real fetch that follows
// does the network housekeeping.
async function applyTiers(rows, live) {
- tiers = rows;
+ setTiers(rows);
for (const t of tiers) {
if (!tierKeys[t.id] && t.wrapped) {
try {
@@ -2816,7 +2806,7 @@ function renderApp() {
// A fresh join (set by the join flow) wins — land straight in that new chat.
const justJoined = localStorage.getItem("lchat_open_thread");
if (justJoined) localStorage.removeItem("lchat_open_thread");
- pendingJoinThread = justJoined || pendingJoinThread; // the welcome conversation's "Say hi" chip needs it later
+ setPendingJoinThread(justJoined || pendingJoinThread); // the welcome conversation's "Say hi" chip needs it later
if (justJoined && threads.some((t) => t.id === justJoined)) {
target = justJoined;
} else if (deep && threads.some((t) => t.id === deep)) {
diff --git a/web/static/shellstate.js b/web/static/shellstate.js
index 724abb3..d731b45 100644
--- a/web/static/shellstate.js
+++ b/web/static/shellstate.js
@@ -27,6 +27,16 @@ export let threadKeyB64 = {}; // thread id -> raw key (base64url), to wrap for n
export let unread = {}; // thread id -> bool
export let hidden = {}; // thread id -> bool: closed (hidden from the list), synced from the server
export let dmClosed = {}; // thread id -> bool: DM ended by either party (server-enforced)
+export let pinned = {}; // thread id -> bool: pinned to the top of the list, synced from the server
+export let muted = {}; // thread id -> bool: muted (no notifications), synced from the server
+export let sealedBranding = null; // decrypted {name, description, admin_name, admin_email, avatar_alt, avatar_mime}
+export let threadApp = {}; // thread id -> mini-app name, for a kind=="self" app-thread
+export let tiers = []; // /api/tiers rows, personalised to this user
+export let tierKeys = {}; // tier id -> CryptoKey
+export let tierKeyB64 = {}; // tier id -> raw base64url (kept to wrap for others)
+export let pendingJoinThread = null; // the DM a fresh invite created — the welcome conversation offers it at the end
+export const ACTIVE_KEY = "lchat_active"; // last-open thread id, so a refresh lands back in it
+export const MAX_UPLOAD = 25 * 1024 * 1024; // matches the default upload cap (main.go)
export function setSession(v) { session = v; }
export function setRoster(v) { roster = v; }
@@ -35,6 +45,9 @@ export function setThreads(v) { threads = v; }
export function setThreadKeys(v) { threadKeys = v; }
export function setActiveId(v) { activeId = v; }
export function setWs(v) { ws = v; }
+export function setTiers(v) { tiers = v; }
+export function setPendingJoinThread(v) { pendingJoinThread = v; }
+export function setSealedBranding(v) { sealedBranding = v; }
export function setNicknames(v) { nicknames = v; }
export function setThreadName(v) { threadName = v; }
export function setInstanceKey(key, b64) { instanceKey = key; instanceKeyB64 = b64; }