Skip to content

Commit 6faeb6d

Browse files
feat(web): add /api/avatar resolver (#1159)
* 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> * chore(web): add CHANGELOG entry for /api/avatar resolver Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(web): validate /api/avatar query params with Zod Replaces the manual `searchParams.get('email')` + plain-text 400 response with the Zod safeParse + queryParamsSchemaValidationError pattern used elsewhere in the API. Errors now return structured JSON consistent with the rest of the public API surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feedback --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cbf50e7 commit 6faeb6d

5 files changed

Lines changed: 92 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Added collapsible file diffs in the commit diff panel. [#1157](https://github.com/sourcebot-dev/sourcebot/pull/1157)
1616
- Added `/api/blame` to the public API to fetch per-line blame information for a file at a given revision. [#1158](https://github.com/sourcebot-dev/sourcebot/pull/1158)
1717

18+
### Changed
19+
- Added `/api/avatar` to resolve user profile pictures. [#1159](https://github.com/sourcebot-dev/sourcebot/pull/1159)
20+
1821
### Fixed
1922
- Bumped `postcss` to `8.5.10`. [#1155](https://github.com/sourcebot-dev/sourcebot/pull/1155)
2023

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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use server';
2+
3+
import { minidenticon } from 'minidenticons';
4+
import { NextRequest } from 'next/server';
5+
import { z } from 'zod';
6+
import { apiHandler } from '@/lib/apiHandler';
7+
import { queryParamsSchemaValidationError, serviceErrorResponse } from '@/lib/serviceError';
8+
import { isServiceError } from '@/lib/utils';
9+
import { withOptionalAuth } from '@/middleware/withAuth';
10+
11+
const queryParamsSchema = z.object({
12+
email: z.string().min(1),
13+
});
14+
15+
// Resolves an email to an avatar image. If the email belongs to a Sourcebot
16+
// user in the requester's org and that user has a profile image set, the
17+
// request is redirected to that URL. Otherwise a minidenticon SVG is returned.
18+
//
19+
// We never 4xx on this endpoint — even if the requester is unauthenticated or
20+
// the user isn't found, we serve the identicon so the avatar visually renders.
21+
export const GET = apiHandler(async (request: NextRequest) => {
22+
const rawParams = Object.fromEntries(
23+
Object.keys(queryParamsSchema.shape).map(key => [
24+
key,
25+
request.nextUrl.searchParams.get(key) ?? undefined,
26+
])
27+
);
28+
const parsed = queryParamsSchema.safeParse(rawParams);
29+
30+
if (!parsed.success) {
31+
return serviceErrorResponse(
32+
queryParamsSchemaValidationError(parsed.error)
33+
);
34+
}
35+
36+
const { email } = parsed.data;
37+
38+
const lookup = await withOptionalAuth(async ({ org, prisma }) => {
39+
return prisma.user.findFirst({
40+
where: {
41+
email,
42+
orgs: { some: { orgId: org.id } },
43+
},
44+
select: { image: true },
45+
});
46+
});
47+
48+
if (!isServiceError(lookup) && lookup?.image) {
49+
return new Response(null, {
50+
status: 302,
51+
headers: {
52+
'Location': lookup.image,
53+
'Cache-Control': 'public, max-age=300',
54+
},
55+
});
56+
}
57+
58+
// Fallback: identicon. Cache lifetime matches the redirect path so the
59+
// response naturally revalidates as users sign up, set profile pictures,
60+
// or transient lookup errors recover.
61+
const svg = minidenticon(email, 50, 50);
62+
return new Response(svg, {
63+
status: 200,
64+
headers: {
65+
'Content-Type': 'image/svg+xml',
66+
'Cache-Control': 'public, max-age=300',
67+
},
68+
});
69+
}, { 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)