diff --git a/CHANGELOG.md b/CHANGELOG.md
index d2913a6c5..5e5d0fc48 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added collapsible file diffs in the commit diff panel. [#1157](https://github.com/sourcebot-dev/sourcebot/pull/1157)
- 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)
+### Changed
+- Added `/api/avatar` to resolve user profile pictures. [#1159](https://github.com/sourcebot-dev/sourcebot/pull/1159)
+
### Fixed
- Bumped `postcss` to `8.5.10`. [#1155](https://github.com/sourcebot-dev/sourcebot/pull/1155)
diff --git a/packages/web/src/app/(app)/browse/components/commitParts.tsx b/packages/web/src/app/(app)/browse/components/commitParts.tsx
index bc7033acb..ce66bfffc 100644
--- a/packages/web/src/app/(app)/browse/components/commitParts.tsx
+++ b/packages/web/src/app/(app)/browse/components/commitParts.tsx
@@ -24,6 +24,7 @@ export const AuthorsAvatarGroup = ({ authors, className }: AuthorsAvatarGroupPro
))}
diff --git a/packages/web/src/app/api/(server)/avatar/route.ts b/packages/web/src/app/api/(server)/avatar/route.ts
new file mode 100644
index 000000000..5cb8e0edb
--- /dev/null
+++ b/packages/web/src/app/api/(server)/avatar/route.ts
@@ -0,0 +1,69 @@
+'use server';
+
+import { minidenticon } from 'minidenticons';
+import { NextRequest } from 'next/server';
+import { z } from 'zod';
+import { apiHandler } from '@/lib/apiHandler';
+import { queryParamsSchemaValidationError, serviceErrorResponse } from '@/lib/serviceError';
+import { isServiceError } from '@/lib/utils';
+import { withOptionalAuth } from '@/middleware/withAuth';
+
+const queryParamsSchema = z.object({
+ email: z.string().min(1),
+});
+
+// Resolves an email to an avatar image. If the email belongs to a Sourcebot
+// user in the requester's org and that user has a profile image set, the
+// request is redirected to that URL. Otherwise a minidenticon SVG is returned.
+//
+// We never 4xx on this endpoint — even if the requester is unauthenticated or
+// the user isn't found, we serve the identicon so the avatar visually renders.
+export const GET = apiHandler(async (request: NextRequest) => {
+ const rawParams = Object.fromEntries(
+ Object.keys(queryParamsSchema.shape).map(key => [
+ key,
+ request.nextUrl.searchParams.get(key) ?? undefined,
+ ])
+ );
+ const parsed = queryParamsSchema.safeParse(rawParams);
+
+ if (!parsed.success) {
+ return serviceErrorResponse(
+ queryParamsSchemaValidationError(parsed.error)
+ );
+ }
+
+ const { email } = parsed.data;
+
+ const lookup = await withOptionalAuth(async ({ org, prisma }) => {
+ return prisma.user.findFirst({
+ where: {
+ email,
+ orgs: { some: { orgId: org.id } },
+ },
+ select: { image: true },
+ });
+ });
+
+ if (!isServiceError(lookup) && lookup?.image) {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ 'Location': lookup.image,
+ 'Cache-Control': 'public, max-age=300',
+ },
+ });
+ }
+
+ // Fallback: identicon. Cache lifetime matches the redirect path so the
+ // response naturally revalidates as users sign up, set profile pictures,
+ // or transient lookup errors recover.
+ const svg = minidenticon(email, 50, 50);
+ return new Response(svg, {
+ status: 200,
+ headers: {
+ 'Content-Type': 'image/svg+xml',
+ 'Cache-Control': 'public, max-age=300',
+ },
+ });
+}, { track: false });
diff --git a/packages/web/src/components/userAvatar.tsx b/packages/web/src/components/userAvatar.tsx
index 77ecda8b3..878935df7 100644
--- a/packages/web/src/components/userAvatar.tsx
+++ b/packages/web/src/components/userAvatar.tsx
@@ -1,8 +1,7 @@
'use client';
-import { minidenticon } from 'minidenticons';
import { ComponentPropsWithoutRef, forwardRef, useMemo } from 'react';
-import { Avatar, AvatarImage } from '@/components/ui/avatar';
+import { Avatar } from '@/components/ui/avatar';
import { cn } from '@/lib/utils';
interface UserAvatarProps extends ComponentPropsWithoutRef {
@@ -12,16 +11,31 @@ interface UserAvatarProps extends ComponentPropsWithoutRef {
export const UserAvatar = forwardRef(
({ email, imageUrl, className, ...rest }, ref) => {
- const identiconUri = useMemo(() => {
+ const resolverUri = useMemo(() => {
if (!email) {
return undefined;
}
- return 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(email, 50, 50));
+ return `/api/avatar?email=${encodeURIComponent(email)}`;
}, [email]);
+ const src = imageUrl ?? resolverUri;
+
return (
-
+ {/*
+ We render a raw
instead of Radix's . AvatarImage
+ delays painting until its internal `new Image().onload` fires —
+ which is async even when the URL is in HTTP cache — and that
+ one-frame gap manifests as a flicker every time a marker mounts
+ (e.g., on scroll). The browser paints cached
synchronously.
+ */}
+ {src && (
+
+ )}
);
}
diff --git a/packages/web/src/features/chat/components/chatThread/messageAvatar.tsx b/packages/web/src/features/chat/components/chatThread/messageAvatar.tsx
deleted file mode 100644
index 6261a631a..000000000
--- a/packages/web/src/features/chat/components/chatThread/messageAvatar.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-'use client';
-
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
-import { cn } from '@/lib/utils';
-import { useThemeNormalized } from '@/hooks/useThemeNormalized';
-import { useSession } from 'next-auth/react';
-import { SBChatMessage } from '../../types';
-import { UserAvatar } from '@/components/userAvatar';
-
-interface MessageAvatarProps {
- role: SBChatMessage['role'];
- className?: string;
-}
-
-export const MessageAvatar = ({ role, className }: MessageAvatarProps) => {
- // @todo: this should be based on the user who initiated the conversation.
- const { data: session } = useSession();
- const { theme } = useThemeNormalized();
-
- if (role === "user") {
- return (
-
- );
- }
-
- return (
-
- AI
-
-
- )
-}
-