|
| 1 | +'use client' |
| 2 | + |
1 | 3 | import type * as React from 'react' |
| 4 | +import { useEffect, useState } from 'react' |
2 | 5 | import { cn } from '../../lib/utils' |
3 | 6 |
|
4 | | -// A deterministic, offline avatar: initials drawn on a stable background color |
5 | | -// derived from the author's identity. No network round-trip (keeping the |
6 | | -// dashboard self-contained), yet every author reads as a distinct chip — the |
7 | | -// same visual role the photo avatar plays in the commit hover card. |
| 7 | +// A commit author avatar. We try to resolve a real portrait from the author's |
| 8 | +// identity — a GitHub avatar for `noreply` commit emails, otherwise Gravatar — |
| 9 | +// and fall back to a deterministic initials chip when there's no image (or no |
| 10 | +// network). The chip is always rendered underneath, so the image simply paints |
| 11 | +// over it once it loads and reappears on error. |
8 | 12 |
|
9 | 13 | // Saturated-but-legible hues that sit well behind white text in both themes. |
10 | 14 | const AVATAR_COLORS = [ |
@@ -38,26 +42,81 @@ function initialsOf(name: string, email?: string): string { |
38 | 42 | return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() |
39 | 43 | } |
40 | 44 |
|
| 45 | +async function sha256Hex(input: string): Promise<string> { |
| 46 | + const bytes = new TextEncoder().encode(input) |
| 47 | + const digest = await crypto.subtle.digest('SHA-256', bytes) |
| 48 | + return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join('') |
| 49 | +} |
| 50 | + |
| 51 | +// `[<n>+]<user>@users.noreply.github.com` → the GitHub username. |
| 52 | +const GITHUB_NOREPLY = /^(?:\d+\+)?([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)@users\.noreply\.github\.com$/i |
| 53 | + |
| 54 | +/** |
| 55 | + * Resolve an avatar image URL for a commit email. GitHub noreply addresses map |
| 56 | + * straight to the user's GitHub avatar; everything else resolves via Gravatar |
| 57 | + * (`d=404` so unregistered emails fail the request and fall back to initials). |
| 58 | + * Returns `null` when no email or no crypto (insecure context) is available. |
| 59 | + */ |
| 60 | +async function resolveAvatarUrl(email: string, size: number): Promise<string | null> { |
| 61 | + const normalized = email.trim().toLowerCase() |
| 62 | + if (!normalized || !normalized.includes('@')) |
| 63 | + return null |
| 64 | + |
| 65 | + const github = normalized.match(GITHUB_NOREPLY) |
| 66 | + if (github) |
| 67 | + return `https://github.com/${github[1]}.png?size=${size}` |
| 68 | + |
| 69 | + if (!globalThis.crypto?.subtle) |
| 70 | + return null |
| 71 | + const hash = await sha256Hex(normalized) |
| 72 | + return `https://gravatar.com/avatar/${hash}?s=${size}&d=404` |
| 73 | +} |
| 74 | + |
41 | 75 | export function Avatar({ |
42 | 76 | name, |
43 | 77 | email, |
| 78 | + size = 64, |
44 | 79 | className, |
45 | 80 | ...props |
46 | | -}: { name: string, email?: string } & React.ComponentProps<'span'>) { |
| 81 | +}: { name: string, email?: string, size?: number } & React.ComponentProps<'span'>) { |
47 | 82 | const key = (email || name || '?').toLowerCase() |
48 | 83 | const color = AVATAR_COLORS[hashString(key) % AVATAR_COLORS.length] |
| 84 | + const [src, setSrc] = useState<string | null>(null) |
| 85 | + const [failed, setFailed] = useState(false) |
| 86 | + |
| 87 | + useEffect(() => { |
| 88 | + let alive = true |
| 89 | + setSrc(null) |
| 90 | + setFailed(false) |
| 91 | + resolveAvatarUrl(email ?? '', size) |
| 92 | + .then(url => alive && setSrc(url)) |
| 93 | + .catch(() => {}) |
| 94 | + return () => { |
| 95 | + alive = false |
| 96 | + } |
| 97 | + }, [email, size]) |
| 98 | + |
49 | 99 | return ( |
50 | 100 | <span |
51 | | - aria-hidden |
52 | 101 | className={cn( |
53 | | - 'inline-flex size-7 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold text-white select-none', |
| 102 | + 'relative inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full text-[11px] font-semibold text-white select-none', |
54 | 103 | className, |
55 | 104 | )} |
56 | 105 | style={{ backgroundColor: color }} |
57 | 106 | title={name} |
58 | 107 | {...props} |
59 | 108 | > |
60 | 109 | {initialsOf(name, email)} |
| 110 | + {src && !failed && ( |
| 111 | + <img |
| 112 | + src={src} |
| 113 | + alt="" |
| 114 | + loading="lazy" |
| 115 | + referrerPolicy="no-referrer" |
| 116 | + onError={() => setFailed(true)} |
| 117 | + className="absolute inset-0 size-full rounded-full object-cover" |
| 118 | + /> |
| 119 | + )} |
61 | 120 | </span> |
62 | 121 | ) |
63 | 122 | } |
0 commit comments