Skip to content
187 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 /** The uploaded avatar's hash, as identity returns it. */
59 image?: string | null;
60 /** g1t itself (a user of kind `system`), whatever the name. */
61 system?: boolean;
62 /** An agent's avatar: g1t's mark, its picture or its initial, with the agent marker. */
63 agent?: boolean;
64 /** The colour behind the avatar, for the agent marker's ring; the page's by default. */
65 ring?: string;
66}) {
67 const [failed, setFailed] = useState<string | null>(null);
68 const usercontent = usercontentFrom(useRouteLoaderData("root"));
69 const g1t = system || isSystemName(name);
70 if (agent) {
71 const radius = Math.round(size * 0.26);
72 const face = g1t ? (
73 <span
74 className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark"
75 style={{ width: size, height: size, borderRadius: radius }}
76 >
77 <Mark className="size-full" />
78 </span>
79 ) : image && failed !== image ? (
80 <img
81 src={avatarUrl(image, usercontent)}
82 alt=""
83 width={size}
84 height={size}
85 loading="lazy"
86 decoding="async"
87 onError={() => setFailed(image)}
88 className="inline-block shrink-0 bg-raised object-cover"
89 style={{ width: size, height: size, borderRadius: radius }}
90 />
91 ) : (
92 <span
93 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"
94 style={{ width: size, height: size, borderRadius: radius, fontSize: size * 0.5 }}
95 >
96 {name[0] ?? "?"}
97 </span>
98 );
99 return (
100 <span role="img" aria-label={`${name}, agent`} className="relative inline-flex shrink-0 align-middle">
101 {face}
102 {size >= 16 && <AgentMarker size={size} ring={ring} />}
103 </span>
104 );
105 }
106 // g1t itself wears its own mark: the pixel 1 on a dark square.
107 if (g1t) {
108 return (
109 <span
110 aria-hidden="true"
111 className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark"
112 style={{ width: size, height: size, borderRadius: size * 0.24 }}
113 >
114 <Mark className="size-full" />
115 </span>
116 );
117 }
118 // ghost stands in for deleted accounts: a plain silhouette, as for
119 // anyone on a commit who has no account.
120 if (name === "ghost" && !image) {
121 return (
122 <span
123 aria-hidden="true"
124 className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint"
125 style={{ width: size, height: size }}
126 >
127 <User size={Math.round(size * 0.62)} strokeWidth={2.25} />
128 </span>
129 );
130 }
131 if (image && failed !== image) {
132 return (
133 <img
134 src={avatarUrl(image, usercontent)}
135 alt=""
136 aria-hidden="true"
137 width={size}
138 height={size}
139 loading="lazy"
140 decoding="async"
141 onError={() => setFailed(image)}
142 className="inline-block shrink-0 bg-raised object-cover"
143 style={{ width: size, height: size, borderRadius: square ? size * 0.24 : size }}
144 />
145 );
146 }
147 let hash = 0;
148 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
149 const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length];
150 return (
151 <span
152 aria-hidden="true"
153 className="inline-flex shrink-0 items-center justify-center font-mono font-semibold uppercase"
154 style={{
155 width: size,
156 height: size,
157 borderRadius: square ? size * 0.24 : size,
158 fontSize: size * 0.5,
159 background: `oklch(0.4 0.09 ${hue})`,
160 color: `oklch(0.93 0.08 ${hue})`,
161 }}
162 >
163 {name[0]}
164 </span>
165 );
166}
167
168/**
169 * The agent marker: a small bot on an accent disc, ringed in the colour
170 * behind it, in the corner of every agent's avatar from 16 px up (under
171 * that the squircle tile alone says it). The same mark everywhere, so
172 * nobody has to read a name to know they are looking at an agent.
173 */
174export function AgentMarker({ size, ring = "var(--color-bg)", className }: { size: number; ring?: string; className?: string }) {
175 const disc = Math.max(9, Math.min(22, Math.round(size * 0.4)));
176 const icon = Math.max(6, Math.round(disc * 0.66));
177 const inset = Math.round(disc * 0.28);
178 return (
179 <span
180 aria-hidden="true"
181 className={`absolute flex items-center justify-center rounded-full bg-accent text-bg ${className ?? ""}`}
182 style={{ width: disc, height: disc, right: -inset, bottom: -inset, boxShadow: `0 0 0 ${size >= 28 ? 2 : 1.5}px ${ring}` }}
183 >
184 <Bot size={icon} strokeWidth={2.6} absoluteStrokeWidth={false} />
185 </span>
186 );
187}