eleven / ocho Public

web: gather the avatar pieces into avatars.js (#958)

avatarFor/threadAvatarFor (decrypted-photo caches; instance key /
thread key) plus the paintAvatar/avatarEl/avatarColor painters,
verbatim from shell.js's three avatar sites. instanceKey +
instanceKeyB64 join shellstate behind one paired setter (five
assignment pairs converted). Called-identifier audit clean.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b6d953d828666b9d67cb8158eaeb6702c49f45f5 parent d6fe324
4 files changed, +103 −90
  • CLAUDE.md +5 −1
  • web/static/avatars.js +85 −0
  • web/static/shell.js +10 −89
  • web/static/shellstate.js +3 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 87b67b3..1a8aab6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -191,7 +191,11 @@ Frontend module layout:
`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.
+ `wireSearch` hook; `search.js` stays the pure index.
+- `avatars.js` — the decrypted-photo cache + initials/photo painters
+ (`avatarFor`/`threadAvatarFor`/`paintAvatar`/`avatarEl`).
+- `shellverbs.js` — the behaviour seam: an empty registry shell.js fills at
+ boot; modules call `verbs.openThread(id)` etc. Registration only.
- `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/avatars.js b/web/static/avatars.js
new file mode 100644
index 0000000..f214a6f
--- /dev/null
+++ b/web/static/avatars.js
@@ -0,0 +1,85 @@
+// Avatars — the decrypted-photo cache and the initials/photo painters.
+// avatarFor unwraps a member's profile photo with the instance key;
+// threadAvatarFor a group's photo with its thread key; paintAvatar/avatarEl
+// paint initials immediately and swap the photo in when a loader resolves.
+// Gathered from shell.js's three avatar sites (docs/shell-modules.md).
+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)
+
+// A member's decrypted profile-photo object URL (instance key), or null.
+export async function avatarFor(userId) {
+ if (avatarURLs[userId]) return avatarURLs[userId];
+ const e = roster.find((x) => x.user_id === userId);
+ // The live agent Eleven is the UPGRADED built-in helper, not a stranger:
+ // until someone gives it a custom photo it wears the app icon — the same
+ // face as the scripted welcome/setup conversations (one Eleven).
+ if (e && e.is_agent && !e.avatar && /^eleven$/i.test(e.label || "")) return "/static/icon-192.png";
+ if (!e || !e.avatar || !instanceKey) return null;
+ try {
+ const buf = new Uint8Array(
+ await (await fetch(`/api/blob/${encodeURIComponent(e.avatar)}?token=${encodeURIComponent(session.token)}`)).arrayBuffer()
+ );
+ const url = URL.createObjectURL(new Blob([await decryptBytes(instanceKey, buf)]));
+ avatarURLs[userId] = url;
+ return url;
+ } catch {
+ return null;
+ }
+}
+
+// A group's decrypted photo object URL (thread key), or null.
+export async function threadAvatarFor(threadId) {
+ const cacheKey = "t:" + threadId;
+ if (avatarURLs[cacheKey]) return avatarURLs[cacheKey];
+ const t = threads.find((x) => x.id === threadId);
+ const key = threadKeys[threadId];
+ if (!t || !t.avatar || !key) return null;
+ try {
+ const buf = new Uint8Array(
+ await (await fetch(`/api/blob/${encodeURIComponent(t.avatar)}?token=${encodeURIComponent(session.token)}`)).arrayBuffer()
+ );
+ const url = URL.createObjectURL(new Blob([await decryptBytes(key, buf)]));
+ avatarURLs[cacheKey] = url;
+ return url;
+ } catch {
+ return null;
+ }
+}
+
+function avatarColor(name) {
+ let h = 0;
+ for (const ch of name || "?") h = (h * 31 + ch.charCodeAt(0)) >>> 0;
+ return `hsl(${h % 360} 55% 52%)`;
+}
+// Paint an initials circle; if a photo `loader` (() => Promise<url|null>) is given,
+// swap the photo in once it resolves.
+// Paints initials immediately, then the photo (if any) when its loader resolves.
+// Returns a promise of the photo URL (or null), so callers can react to it —
+// e.g. the header avatar wires a tap-to-zoom only when there's a real photo.
+export function paintAvatar(el, name, loader, alt) {
+ // Avatars are background-image spans, invisible to screen readers without
+ // this: the member's own (encrypted) photo description wins over their name.
+ el.setAttribute("role", "img");
+ el.setAttribute("aria-label", alt || name || "");
+ el.style.backgroundImage = "";
+ el.textContent = ((name || "?").trim().charAt(0) || "?").toUpperCase();
+ el.style.background = avatarColor(name);
+ el.style.backgroundSize = "cover";
+ el.style.backgroundPosition = "center";
+ if (!loader) return Promise.resolve(null);
+ return loader().then((url) => {
+ if (url) {
+ el.textContent = "";
+ el.style.backgroundImage = `url(${url})`;
+ }
+ return url || null;
+ });
+}
+export function avatarEl(name, loader) {
+ const el = document.createElement("span");
+ el.className = "avatar";
+ paintAvatar(el, name, loader);
+ return el;
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index ba3e0e7..07fd5a6 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -133,11 +133,12 @@ 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 { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js";
import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md)
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 } 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 } 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";
@@ -553,8 +554,6 @@ function showThemePicker() {
const preMountStaged = []; // native-staged files awaiting the chat app's composer (flushed by registerNative)
const blobURLs = {}; // blob id -> decrypted object URL (cache)
-let instanceKey = null; // shared key for all-members content (profile photos)
-let instanceKeyB64 = null;
// ---- sealed branding (docs/no-plaintext.md) -------------------------------
// On a sealed instance the space's name/description/photo/admin identity live
@@ -785,7 +784,6 @@ async function unsealBrandingNow() {
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)
-const avatarURLs = {}; // user id -> decrypted profile-photo object URL (cache)
// The fixed reaction set (mirrors allowedReactions in hub.go).
const REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
@@ -1044,8 +1042,8 @@ async function hydrateFromCache() {
await applyRoster(snap.roster);
if (snap.instance && snap.instance.wrapped) {
try {
- instanceKeyB64 = await unwrapThreadKey(snap.instance.wrapped, session.userPriv);
- instanceKey = await importKey(instanceKeyB64);
+ const b64 = await unwrapThreadKey(snap.instance.wrapped, session.userPriv);
+ setInstanceKey(await importKey(b64), b64);
} catch {}
}
if (snap.nick) {
@@ -1216,8 +1214,7 @@ async function bootEmbed() {
await loadRoster(); // token-only; gives sender labels + profile photos
if (embed.instanceKey) {
try {
- instanceKeyB64 = embed.instanceKey;
- instanceKey = await importKey(embed.instanceKey);
+ setInstanceKey(await importKey(embed.instanceKey), embed.instanceKey);
} catch {}
}
// Per-viewer names (#577): the embed never holds userPriv, so it can't
@@ -1902,16 +1899,15 @@ async function setupInstanceKey() {
bootRaw.instance = { wrapped: r.wrapped };
scheduleStoreSnapshot();
try {
- instanceKeyB64 = await unwrapThreadKey(r.wrapped, session.userPriv);
- instanceKey = await importKey(instanceKeyB64);
+ const b64 = await unwrapThreadKey(r.wrapped, session.userPriv);
+ setInstanceKey(await importKey(b64), b64);
} catch {
return;
}
await grantInstanceKeyTo(r.have || []);
} else if (!(r.have || []).length) {
const k = await generateKey(); // first member: bootstrap the instance key
- instanceKey = k.key;
- instanceKeyB64 = k.b64;
+ setInstanceKey(k.key, k.b64);
await grantInstanceKeyTo([]);
} else {
// It exists but we don't hold a copy yet. Nudge any online holder to wrap it
@@ -1950,8 +1946,8 @@ async function pollForInstanceKey(tries = 24) {
if ("holders_online" in r) keyHolderState = { online: !!r.holders_online, lastActive: r.holders_last_active || 0 };
if (r.wrapped) {
try {
- instanceKeyB64 = await unwrapThreadKey(r.wrapped, session.userPriv);
- instanceKey = await importKey(instanceKeyB64);
+ const b64 = await unwrapThreadKey(r.wrapped, session.userPriv);
+ setInstanceKey(await importKey(b64), b64);
await onInstanceKeyReady();
} catch {}
return;
@@ -2173,46 +2169,6 @@ async function grantInstanceKeyTo(have) {
}
}
-// A member's decrypted profile-photo object URL (instance key), or null.
-async function avatarFor(userId) {
- if (avatarURLs[userId]) return avatarURLs[userId];
- const e = roster.find((x) => x.user_id === userId);
- // The live agent Eleven is the UPGRADED built-in helper, not a stranger:
- // until someone gives it a custom photo it wears the app icon — the same
- // face as the scripted welcome/setup conversations (one Eleven).
- if (e && e.is_agent && !e.avatar && /^eleven$/i.test(e.label || "")) return "/static/icon-192.png";
- if (!e || !e.avatar || !instanceKey) return null;
- try {
- const buf = new Uint8Array(
- await (await fetch(`/api/blob/${encodeURIComponent(e.avatar)}?token=${encodeURIComponent(session.token)}`)).arrayBuffer()
- );
- const url = URL.createObjectURL(new Blob([await decryptBytes(instanceKey, buf)]));
- avatarURLs[userId] = url;
- return url;
- } catch {
- return null;
- }
-}
-
-// A group's decrypted photo object URL (thread key), or null.
-async function threadAvatarFor(threadId) {
- const cacheKey = "t:" + threadId;
- if (avatarURLs[cacheKey]) return avatarURLs[cacheKey];
- const t = threads.find((x) => x.id === threadId);
- const key = threadKeys[threadId];
- if (!t || !t.avatar || !key) return null;
- try {
- const buf = new Uint8Array(
- await (await fetch(`/api/blob/${encodeURIComponent(t.avatar)}?token=${encodeURIComponent(session.token)}`)).arrayBuffer()
- );
- const url = URL.createObjectURL(new Blob([await decryptBytes(key, buf)]));
- avatarURLs[cacheKey] = url;
- return url;
- } catch {
- return null;
- }
-}
-
async function loadThreads() {
const r = await (await fetch(`/api/threads?token=${encodeURIComponent(session.token)}`)).json();
if (Array.isArray(r.threads) && !embed) {
@@ -9643,41 +9599,6 @@ async function addDevice() {
// ---- avatars + profile --------------------------------------------------
// A stable colour from a name, so each person's initial circle is consistent.
-function avatarColor(name) {
- let h = 0;
- for (const ch of name || "?") h = (h * 31 + ch.charCodeAt(0)) >>> 0;
- return `hsl(${h % 360} 55% 52%)`;
-}
-// Paint an initials circle; if a photo `loader` (() => Promise<url|null>) is given,
-// swap the photo in once it resolves.
-// Paints initials immediately, then the photo (if any) when its loader resolves.
-// Returns a promise of the photo URL (or null), so callers can react to it —
-// e.g. the header avatar wires a tap-to-zoom only when there's a real photo.
-function paintAvatar(el, name, loader, alt) {
- // Avatars are background-image spans, invisible to screen readers without
- // this: the member's own (encrypted) photo description wins over their name.
- el.setAttribute("role", "img");
- el.setAttribute("aria-label", alt || name || "");
- el.style.backgroundImage = "";
- el.textContent = ((name || "?").trim().charAt(0) || "?").toUpperCase();
- el.style.background = avatarColor(name);
- el.style.backgroundSize = "cover";
- el.style.backgroundPosition = "center";
- if (!loader) return Promise.resolve(null);
- return loader().then((url) => {
- if (url) {
- el.textContent = "";
- el.style.backgroundImage = `url(${url})`;
- }
- return url || null;
- });
-}
-function avatarEl(name, loader) {
- const el = document.createElement("span");
- el.className = "avatar";
- paintAvatar(el, name, loader);
- return el;
-}
// Edit profile: avatar (photo or initials) + screen name.
function editProfile() {
diff --git a/web/static/shellstate.js b/web/static/shellstate.js
index 0a39378..d8af2f8 100644
--- a/web/static/shellstate.js
+++ b/web/static/shellstate.js
@@ -15,6 +15,8 @@ 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 let instanceKey = null; // the all-members AES key (profile photos), or null until granted
+export let instanceKeyB64 = null;
export function setSession(v) { session = v; }
export function setRoster(v) { roster = v; }
@@ -25,3 +27,4 @@ export function setActiveId(v) { activeId = v; }
export function setWs(v) { ws = v; }
export function setNicknames(v) { nicknames = v; }
export function setThreadName(v) { threadName = v; }
+export function setInstanceKey(key, b64) { instanceKey = key; instanceKeyB64 = b64; }