eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

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).
6import { decryptBytes } from "/static/crypto.js";
7import { session, roster, threads, threadKeys, instanceKey } from "/static/shellstate.js";
8
9export const avatarURLs = {}; // user id -> decrypted profile-photo object URL (cache)
10
11// A member's decrypted profile-photo object URL (instance key), or null.
12export 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.
33export 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
51function 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.
61export 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}
80export function avatarEl(name, loader) {
81 const el = document.createElement("span");
82 el.className = "avatar";
83 paintAvatar(el, name, loader);
84 return el;
85}
86