| 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() { |