Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const AuthorsAvatarGroup = ({ authors, className }: AuthorsAvatarGroupPro
<UserAvatar
key={a.email}
email={a.email}
title={a.email}
className="h-5 w-5"
/>
))}
Expand Down
69 changes: 69 additions & 0 deletions packages/web/src/app/api/(server)/avatar/route.ts
Original file line number Diff line number Diff line change
@@ -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',
},
Comment thread
brendan-kellam marked this conversation as resolved.
});
}, { track: false });
24 changes: 19 additions & 5 deletions packages/web/src/components/userAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Avatar> {
Expand All @@ -12,16 +11,31 @@ interface UserAvatarProps extends ComponentPropsWithoutRef<typeof Avatar> {

export const UserAvatar = forwardRef<HTMLSpanElement, UserAvatarProps>(
({ 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 (
<Avatar ref={ref} className={cn("bg-muted", className)} {...rest}>
<AvatarImage src={imageUrl ?? identiconUri} />
{/*
We render a raw <img> instead of Radix's <AvatarImage>. 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 <img> synchronously.
*/}
{src && (
<img
Comment thread
brendan-kellam marked this conversation as resolved.
src={src}
alt=""
className="aspect-square h-full w-full"
/>
)}
</Avatar>
);
}
Expand Down

This file was deleted.

Loading