Skip to content

Commit 95fcd1a

Browse files
feat(web): add /api/avatar resolver and use it in UserAvatar
Adds a new `/api/avatar?email=<email>` endpoint that resolves an email to either the matching Sourcebot user's profile image (302 redirect with short cache) or a deterministic minidenticon SVG (long-lived immutable cache). Falls back to the identicon on auth or lookup failure so avatars never break for anonymous viewers. Updates `UserAvatar` to compute its src from this resolver instead of generating an inline minidenticon data URI client-side. Every existing call site automatically picks up real profile pictures where the email matches a Sourcebot user — no consumer changes needed. Also swaps Radix's `<AvatarImage>` for a raw `<img>`. AvatarImage delays painting until its internal `new Image().onload` fires (async even from HTTP cache), which manifests as a flicker every time the avatar mounts under aggressive churn (e.g., in a CodeMirror gutter). The browser paints cached `<img>` synchronously. Adds a native `title` tooltip to the displayed avatars in `AuthorsAvatarGroup` and removes the unused `MessageAvatar` wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cbf50e7 commit 95fcd1a

4 files changed

Lines changed: 71 additions & 44 deletions

File tree

packages/web/src/app/(app)/browse/components/commitParts.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export const AuthorsAvatarGroup = ({ authors, className }: AuthorsAvatarGroupPro
2424
<UserAvatar
2525
key={a.email}
2626
email={a.email}
27+
title={a.email}
2728
className="h-5 w-5"
2829
/>
2930
))}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use server';
2+
3+
import { minidenticon } from 'minidenticons';
4+
import { NextRequest } from 'next/server';
5+
import { apiHandler } from '@/lib/apiHandler';
6+
import { isServiceError } from '@/lib/utils';
7+
import { withOptionalAuth } from '@/middleware/withAuth';
8+
9+
// Resolves an email to an avatar image. If the email belongs to a Sourcebot
10+
// user in the requester's org and that user has a profile image set, the
11+
// request is redirected to that URL. Otherwise a minidenticon SVG is returned.
12+
//
13+
// We never 4xx on this endpoint — even if the requester is unauthenticated or
14+
// the user isn't found, we serve the identicon so the avatar visually renders.
15+
export const GET = apiHandler(async (request: NextRequest) => {
16+
const email = request.nextUrl.searchParams.get('email');
17+
if (!email) {
18+
return new Response('Missing email parameter', { status: 400 });
19+
}
20+
21+
const lookup = await withOptionalAuth(async ({ org, prisma }) => {
22+
return prisma.user.findFirst({
23+
where: {
24+
email,
25+
orgs: { some: { orgId: org.id } },
26+
},
27+
select: { image: true },
28+
});
29+
});
30+
31+
if (!isServiceError(lookup) && lookup?.image) {
32+
return new Response(null, {
33+
status: 302,
34+
headers: {
35+
'Location': lookup.image,
36+
'Cache-Control': 'public, max-age=300',
37+
},
38+
});
39+
}
40+
41+
// Fallback: identicons are deterministic from the email so they can be
42+
// cached aggressively.
43+
const svg = minidenticon(email, 50, 50);
44+
return new Response(svg, {
45+
status: 200,
46+
headers: {
47+
'Content-Type': 'image/svg+xml',
48+
'Cache-Control': 'public, max-age=31536000, immutable',
49+
},
50+
});
51+
}, { track: false });

packages/web/src/components/userAvatar.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
'use client';
22

3-
import { minidenticon } from 'minidenticons';
43
import { ComponentPropsWithoutRef, forwardRef, useMemo } from 'react';
5-
import { Avatar, AvatarImage } from '@/components/ui/avatar';
4+
import { Avatar } from '@/components/ui/avatar';
65
import { cn } from '@/lib/utils';
76

87
interface UserAvatarProps extends ComponentPropsWithoutRef<typeof Avatar> {
@@ -12,16 +11,31 @@ interface UserAvatarProps extends ComponentPropsWithoutRef<typeof Avatar> {
1211

1312
export const UserAvatar = forwardRef<HTMLSpanElement, UserAvatarProps>(
1413
({ email, imageUrl, className, ...rest }, ref) => {
15-
const identiconUri = useMemo(() => {
14+
const resolverUri = useMemo(() => {
1615
if (!email) {
1716
return undefined;
1817
}
19-
return 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(email, 50, 50));
18+
return `/api/avatar?email=${encodeURIComponent(email)}`;
2019
}, [email]);
2120

21+
const src = imageUrl ?? resolverUri;
22+
2223
return (
2324
<Avatar ref={ref} className={cn("bg-muted", className)} {...rest}>
24-
<AvatarImage src={imageUrl ?? identiconUri} />
25+
{/*
26+
We render a raw <img> instead of Radix's <AvatarImage>. AvatarImage
27+
delays painting until its internal `new Image().onload` fires —
28+
which is async even when the URL is in HTTP cache — and that
29+
one-frame gap manifests as a flicker every time a marker mounts
30+
(e.g., on scroll). The browser paints cached <img> synchronously.
31+
*/}
32+
{src && (
33+
<img
34+
src={src}
35+
alt=""
36+
className="aspect-square h-full w-full"
37+
/>
38+
)}
2539
</Avatar>
2640
);
2741
}

packages/web/src/features/chat/components/chatThread/messageAvatar.tsx

Lines changed: 0 additions & 39 deletions
This file was deleted.

0 commit comments

Comments
 (0)