Skip to content
226 linesCodeBlameRaw
1import { Bot, User } from "lucide-react";
2import { useState } from "react";
3import { useRouteLoaderData } from "react-router";
4
5import { usercontentFrom } from "../../lib/addresses";
6import { Mark } from "../logo";
7
8// shadcn/ui's avatar, as g1t draws one: an uploaded picture, or a letter
9// whose colour is stable for a given name; an agent's (an old message's
10// author, or g1t's own), with the agent marker in its corner. It is one
11// element rather than Radix's root/image/fallback so the server renders
12// the letter at once and nothing jumps; an image that fails to load falls
13// back to the letter.
14
15const AVATAR_HUES = [82, 200, 262, 28, 330, 160];
16
17/**
18 * g1t itself: the system, as the author of security updates, the issues it
19 * opens and merges from the queue, and on older rows as reviewer, assignee
20 * and commit author. One name, `g1t`.
21 */
22export function isSystemName(name: string | null | undefined): boolean {
23 return name === "g1t";
24}
25
26/**
27 * Where an uploaded avatar is served, from the hash it is stored by: the
28 * usercontent origin, or the site's own address (which redirects there)
29 * when it is not known.
30 */
31export function avatarUrl(avatar: string, usercontent = ""): string {
32 return `${usercontent}/avatars/${avatar}`;
33}
34
35/**
36 * An uploaded avatar if there is one, else a letter avatar whose colour is
37 * stable for a given name. People are round; a workspace is `square`. An
38 * image that fails to load falls back to the letter.
39 *
40 * With `agent`, it is an agent's: g1t's own mark, its uploaded picture, or
41 * its initial on a squircle tile so it reads apart from people's round
42 * letters at any size, and from 16 px up the agent marker in its corner.
43 * It then names itself to a screen reader, "Margo, agent", where a
44 * person's avatar is decoration beside their name.
45 */
46export function Avatar({
47 name,
48 size = 20,
49 square,
50 image,
51 system,
52 agent,
53 ring,
54}: {
55 name: string;
56 size?: number;
57 square?: boolean;
58 /**
59 * The uploaded avatar's hash, as identity returns it; null when there is
60 * none, or `name` is no account's. Left out, a person's picture is looked
61 * up by their username.
62 */
63 image?: string | null;
64 /** g1t itself (a user of kind `system`), whatever the name. */
65 system?: boolean;
66 /** An agent's avatar: g1t's mark, its picture or its initial, with the agent marker. */
67 agent?: boolean;
68 /** The colour behind the avatar, for the agent marker's ring; the page's by default. */
69 ring?: string;
70}) {
71 const [failed, setFailed] = useState<string | null>(null);
72 const [loaded, setLoaded] = useState<string | null>(null);
73 const usercontent = usercontentFrom(useRouteLoaderData("root"));
74 const g1t = system || isSystemName(name);
75 if (agent) {
76 const radius = Math.round(size * 0.26);
77 const face = g1t ? (
78 <span
79 className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark"
80 style={{ width: size, height: size, borderRadius: radius }}
81 >
82 <Mark className="size-full" />
83 </span>
84 ) : image && failed !== image ? (
85 <img
86 src={avatarUrl(image, usercontent)}
87 alt=""
88 width={size}
89 height={size}
90 loading="lazy"
91 decoding="async"
92 onError={() => setFailed(image)}
93 className="inline-block shrink-0 bg-raised object-cover"
94 style={{ width: size, height: size, borderRadius: radius }}
95 />
96 ) : (
97 <span
98 className="inline-flex shrink-0 items-center justify-center bg-raised font-mono font-semibold text-muted uppercase ring-1 ring-line-strong ring-inset"
99 style={{ width: size, height: size, borderRadius: radius, fontSize: size * 0.5 }}
100 >
101 {name[0] ?? "?"}
102 </span>
103 );
104 return (
105 <span role="img" aria-label={`${name}, agent`} className="relative inline-flex shrink-0 align-middle">
106 {face}
107 {size >= 16 && <AgentMarker size={size} ring={ring} />}
108 </span>
109 );
110 }
111 // g1t itself wears its own mark: the pixel 1 on a dark square.
112 if (g1t) {
113 return (
114 <span
115 aria-hidden="true"
116 className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark"
117 style={{ width: size, height: size, borderRadius: size * 0.24 }}
118 >
119 <Mark className="size-full" />
120 </span>
121 );
122 }
123 // ghost stands in for deleted accounts: a plain silhouette, as for
124 // anyone on a commit who has no account.
125 if (name === "ghost" && !image) {
126 return (
127 <span
128 aria-hidden="true"
129 className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint"
130 style={{ width: size, height: size }}
131 >
132 <User size={Math.round(size * 0.62)} strokeWidth={2.25} />
133 </span>
134 );
135 }
136 if (image && failed !== image) {
137 return (
138 <img
139 src={avatarUrl(image, usercontent)}
140 alt=""
141 aria-hidden="true"
142 width={size}
143 height={size}
144 loading="lazy"
145 decoding="async"
146 onError={() => setFailed(image)}
147 className="inline-block shrink-0 bg-raised object-cover"
148 style={{ width: size, height: size, borderRadius: square ? size * 0.24 : size }}
149 />
150 );
151 }
152 let hash = 0;
153 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
154 const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length];
155 const letter = (
156 <span
157 aria-hidden="true"
158 className="inline-flex shrink-0 items-center justify-center font-mono font-semibold uppercase"
159 style={{
160 width: size,
161 height: size,
162 borderRadius: square ? size * 0.24 : size,
163 fontSize: size * 0.5,
164 background: `oklch(0.4 0.09 ${hue})`,
165 color: `oklch(0.93 0.08 ${hue})`,
166 }}
167 >
168 {name[0]}
169 </span>
170 );
171 // A person drawn by username alone (`image` not given, as opposed to
172 // null for "no upload"): their letter at once, and their uploaded
173 // picture over it once it has loaded, from the site's address for it.
174 if (square || image !== undefined || failed === name || !isUsername(name)) return letter;
175 return (
176 <span className="relative inline-flex shrink-0" style={{ width: size, height: size }}>
177 {letter}
178 <img
179 // An image the browser had before the page woke has no load event to come.
180 ref={(img) => {
181 if (img?.complete && img.naturalWidth > 0) setLoaded(name);
182 }}
183 src={`/-/avatar/user/${name.toLowerCase()}`}
184 alt=""
185 aria-hidden="true"
186 width={size}
187 height={size}
188 loading="lazy"
189 decoding="async"
190 onLoad={() => setLoaded(name)}
191 onError={() => setFailed(name)}
192 className={`absolute inset-0 bg-raised object-cover transition-opacity duration-150 ${loaded === name ? "opacity-100" : "opacity-0"}`}
193 style={{ width: size, height: size, borderRadius: size }}
194 />
195 </span>
196 );
197}
198
199/**
200 * A name an account could have: what has a picture to look up. Not ghost,
201 * nor "someone", what a feed says when it could not read who it was.
202 */
203function isUsername(name: string): boolean {
204 return name !== "ghost" && name !== "someone" && /^[a-z0-9-]{1,39}$/i.test(name);
205}
206
207/**
208 * The agent marker: a small bot on an accent disc, ringed in the colour
209 * behind it, in the corner of every agent's avatar from 16 px up (under
210 * that the squircle tile alone says it). The same mark everywhere, so
211 * nobody has to read a name to know they are looking at an agent.
212 */
213export function AgentMarker({ size, ring = "var(--color-bg)", className }: { size: number; ring?: string; className?: string }) {
214 const disc = Math.max(9, Math.min(22, Math.round(size * 0.4)));
215 const icon = Math.max(6, Math.round(disc * 0.66));
216 const inset = Math.round(disc * 0.28);
217 return (
218 <span
219 aria-hidden="true"
220 className={`absolute flex items-center justify-center rounded-full bg-accent text-bg ${className ?? ""}`}
221 style={{ width: disc, height: disc, right: -inset, bottom: -inset, boxShadow: `0 0 0 ${size >= 28 ? 2 : 1.5}px ${ring}` }}
222 >
223 <Bot size={icon} strokeWidth={2.6} absoluteStrokeWidth={false} />
224 </span>
225 );
226}