Skip to content
115 linesCodeBlameRaw
1import { 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. It is one element rather than
10// Radix's root/image/fallback so the server renders the letter at once and
11// nothing jumps; an image that fails to load falls back to the letter.
12
13const AVATAR_HUES = [82, 200, 262, 28, 330, 160];
14
15/**
16 * g1t itself: its agent, as reviewer, assignee and commit author, and the
17 * system, as the author of security updates, the issues it opens and merges
18 * from the queue. One name, `g1t`.
19 */
20export function isSystemName(name: string | null | undefined): boolean {
21 return name === "g1t";
22}
23
24/**
25 * Where an uploaded avatar is served, from the hash it is stored by: the
26 * usercontent origin, or the site's own address (which redirects there)
27 * when it is not known.
28 */
29export function avatarUrl(avatar: string, usercontent = ""): string {
30 return `${usercontent}/avatars/${avatar}`;
31}
32
33/**
34 * An uploaded avatar if there is one, else a letter avatar whose colour is
35 * stable for a given name. People are round; a workspace is `square`. An
36 * image that fails to load falls back to the letter.
37 */
38export function Avatar({
39 name,
40 size = 20,
41 square,
42 image,
43 system,
44}: {
45 name: string;
46 size?: number;
47 square?: boolean;
48 /** The uploaded avatar's hash, as identity returns it. */
49 image?: string | null;
50 /** g1t itself (a user of kind `system`), whatever the name. */
51 system?: boolean;
52}) {
53 const [failed, setFailed] = useState<string | null>(null);
54 const usercontent = usercontentFrom(useRouteLoaderData("root"));
55 // g1t itself wears its own mark: the pixel 1 on a dark square.
56 if (system || isSystemName(name)) {
57 return (
58 <span
59 aria-hidden="true"
60 className="inline-flex shrink-0 items-center justify-center bg-[#0b0b0d] text-fg ring-1 ring-line-strong ring-inset scheme-dark"
61 style={{ width: size, height: size, borderRadius: size * 0.24 }}
62 >
63 <Mark className="size-full" />
64 </span>
65 );
66 }
67 // ghost stands in for deleted accounts: a plain silhouette, as for
68 // anyone on a commit who has no account.
69 if (name === "ghost" && !image) {
70 return (
71 <span
72 aria-hidden="true"
73 className="inline-flex shrink-0 items-center justify-center rounded-full bg-line text-faint"
74 style={{ width: size, height: size }}
75 >
76 <User size={Math.round(size * 0.62)} strokeWidth={2.25} />
77 </span>
78 );
79 }
80 if (image && failed !== image) {
81 return (
82 <img
83 src={avatarUrl(image, usercontent)}
84 alt=""
85 aria-hidden="true"
86 width={size}
87 height={size}
88 loading="lazy"
89 decoding="async"
90 onError={() => setFailed(image)}
91 className="inline-block shrink-0 bg-raised object-cover"
92 style={{ width: size, height: size, borderRadius: square ? size * 0.24 : size }}
93 />
94 );
95 }
96 let hash = 0;
97 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
98 const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length];
99 return (
100 <span
101 aria-hidden="true"
102 className="inline-flex shrink-0 items-center justify-center font-mono font-semibold uppercase"
103 style={{
104 width: size,
105 height: size,
106 borderRadius: square ? size * 0.24 : size,
107 fontSize: size * 0.5,
108 background: `oklch(0.4 0.09 ${hue})`,
109 color: `oklch(0.93 0.08 ${hue})`,
110 }}
111 >
112 {name[0]}
113 </span>
114 );
115}