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 - - - ) -} -