Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
- `UserAvatar` now resolves profile pictures via a new `/api/avatar` endpoint, automatically displaying a user's profile image when their email matches a Sourcebot user. Falls back to a minidenticon otherwise. [#1159](https://github.com/sourcebot-dev/sourcebot/pull/1159)
Comment thread
brendan-kellam marked this conversation as resolved.
Outdated

### 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
51 changes: 51 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,51 @@
'use server';

import { minidenticon } from 'minidenticons';
import { NextRequest } from 'next/server';
import { apiHandler } from '@/lib/apiHandler';
import { isServiceError } from '@/lib/utils';
import { withOptionalAuth } from '@/middleware/withAuth';

// 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 email = request.nextUrl.searchParams.get('email');
if (!email) {
return new Response('Missing email parameter', { status: 400 });
}
Comment thread
brendan-kellam marked this conversation as resolved.
Outdated

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: identicons are deterministic from the email so they can be
// cached aggressively.
const svg = minidenticon(email, 50, 50);
return new Response(svg, {
status: 200,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=31536000, immutable',
},
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