| 1 | import { User } from "lucide-react"; |
| 2 | import { useState } from "react"; |
| 3 | import { useRouteLoaderData } from "react-router"; |
| 4 | |
| 5 | import { usercontentFrom } from "../../lib/addresses"; |
| 6 | import { 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 | |
| 13 | const 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 | */ |
| 20 | export 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 | */ |
| 29 | export 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 | */ |
| 38 | export 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 | } |