// 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"; 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) { 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) 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; }