| 1 | // Avatars — the decrypted-photo cache and the initials/photo painters. |
| 2 | // avatarFor unwraps a member's profile photo with the instance key; |
| 3 | // threadAvatarFor a group's photo with its thread key; paintAvatar/avatarEl |
| 4 | // paint initials immediately and swap the photo in when a loader resolves. |
| 5 | // Gathered from shell.js's three avatar sites (docs/shell-modules.md). |
| 6 | import { decryptBytes } from "/static/crypto.js"; |
| 7 | import { session, roster, threads, threadKeys, instanceKey } from "/static/shellstate.js"; |
| 8 | |
| 9 | export const avatarURLs = {}; // user id -> decrypted profile-photo object URL (cache) |
| 10 | |
| 11 | // A member's decrypted profile-photo object URL (instance key), or null. |
| 12 | export async function avatarFor(userId) { |
| 13 | if (avatarURLs[userId]) return avatarURLs[userId]; |
| 14 | const e = roster.find((x) => x.user_id === userId); |
| 15 | // The live agent Eleven is the UPGRADED built-in helper, not a stranger: |
| 16 | // until someone gives it a custom photo it wears the app icon — the same |
| 17 | // face as the scripted welcome/setup conversations (one Eleven). |
| 18 | if (e && e.is_agent && !e.avatar && /^eleven$/i.test(e.label || "")) return "/static/icon-192.png"; |
| 19 | if (!e || !e.avatar || !instanceKey) return null; |
| 20 | try { |
| 21 | const buf = new Uint8Array( |
| 22 | await (await fetch(`/api/blob/${encodeURIComponent(e.avatar)}?token=${encodeURIComponent(session.token)}`)).arrayBuffer() |
| 23 | ); |
| 24 | const url = URL.createObjectURL(new Blob([await decryptBytes(instanceKey, buf)])); |
| 25 | avatarURLs[userId] = url; |
| 26 | return url; |
| 27 | } catch { |
| 28 | return null; |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | // A group's decrypted photo object URL (thread key), or null. |
| 33 | export async function threadAvatarFor(threadId) { |
| 34 | const cacheKey = "t:" + threadId; |
| 35 | if (avatarURLs[cacheKey]) return avatarURLs[cacheKey]; |
| 36 | const t = threads.find((x) => x.id === threadId); |
| 37 | const key = threadKeys[threadId]; |
| 38 | if (!t || !t.avatar || !key) return null; |
| 39 | try { |
| 40 | const buf = new Uint8Array( |
| 41 | await (await fetch(`/api/blob/${encodeURIComponent(t.avatar)}?token=${encodeURIComponent(session.token)}`)).arrayBuffer() |
| 42 | ); |
| 43 | const url = URL.createObjectURL(new Blob([await decryptBytes(key, buf)])); |
| 44 | avatarURLs[cacheKey] = url; |
| 45 | return url; |
| 46 | } catch { |
| 47 | return null; |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | function avatarColor(name) { |
| 52 | let h = 0; |
| 53 | for (const ch of name || "?") h = (h * 31 + ch.charCodeAt(0)) >>> 0; |
| 54 | return `hsl(${h % 360} 55% 52%)`; |
| 55 | } |
| 56 | // Paint an initials circle; if a photo `loader` (() => Promise<url|null>) is given, |
| 57 | // swap the photo in once it resolves. |
| 58 | // Paints initials immediately, then the photo (if any) when its loader resolves. |
| 59 | // Returns a promise of the photo URL (or null), so callers can react to it — |
| 60 | // e.g. the header avatar wires a tap-to-zoom only when there's a real photo. |
| 61 | export function paintAvatar(el, name, loader, alt) { |
| 62 | // Avatars are background-image spans, invisible to screen readers without |
| 63 | // this: the member's own (encrypted) photo description wins over their name. |
| 64 | el.setAttribute("role", "img"); |
| 65 | el.setAttribute("aria-label", alt || name || ""); |
| 66 | el.style.backgroundImage = ""; |
| 67 | el.textContent = ((name || "?").trim().charAt(0) || "?").toUpperCase(); |
| 68 | el.style.background = avatarColor(name); |
| 69 | el.style.backgroundSize = "cover"; |
| 70 | el.style.backgroundPosition = "center"; |
| 71 | if (!loader) return Promise.resolve(null); |
| 72 | return loader().then((url) => { |
| 73 | if (url) { |
| 74 | el.textContent = ""; |
| 75 | el.style.backgroundImage = `url(${url})`; |
| 76 | } |
| 77 | return url || null; |
| 78 | }); |
| 79 | } |
| 80 | export function avatarEl(name, loader) { |
| 81 | const el = document.createElement("span"); |
| 82 | el.className = "avatar"; |
| 83 | paintAvatar(el, name, loader); |
| 84 | return el; |
| 85 | } |
| 86 | |