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 @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- Replaced placeholder avatars with deterministic minidenticon-based avatars generated from email addresses [#1072](https://github.com/sourcebot-dev/sourcebot/pull/1072)

## [4.16.4] - 2026-04-01

### Added
Expand Down
5 changes: 3 additions & 2 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
"linguist-languages": "^9.3.1",
"lucide-react": "^0.517.0",
"micromatch": "^4.0.8",
"minidenticons": "^4.2.1",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"next-navigation-guard": "^0.2.0",
Expand Down Expand Up @@ -195,7 +196,7 @@
"devDependencies": {
"@asteasolutions/zod-to-openapi": "7.3.4",
"@eslint/eslintrc": "^3",
"@react-email/preview-server": "5.2.8",
"@react-email/preview-server": "5.2.10",
"@react-grab/mcp": "^0.1.23",
"@tanstack/eslint-plugin-query": "^5.74.7",
"@testing-library/dom": "^10.4.1",
Expand All @@ -218,7 +219,7 @@
"npm-run-all": "^4.1.5",
"postcss": "^8",
"raw-loader": "^4.0.2",
"react-email": "^5.1.0",
"react-email": "^5.2.10",
"react-grab": "^0.1.23",
"react-scan": "^0.5.3",
"tailwindcss": "^3.4.1",
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea
const inviteLink = `${env.AUTH_URL}/redeem?invite_id=${invite.id}`;
const transport = createTransport(smtpConnectionUrl);
const html = await render(InviteUserEmail({
baseUrl: env.AUTH_URL,
host: {
name: user.name ?? undefined,
email: user.email!,
Expand Down Expand Up @@ -999,6 +1000,7 @@ export const getOrgAccountRequests = async () => sew(() =>
email: request.requestedBy.email!,
createdAt: request.createdAt,
name: request.requestedBy.name ?? undefined,
image: request.requestedBy.image ?? undefined,
}));
}));

Expand Down
6 changes: 5 additions & 1 deletion packages/web/src/app/[domain]/chat/[id]/opengraph-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { prisma } from '@/prisma';
import { getOrgFromDomain } from '@/data/org';
import { ChatVisibility } from '@sourcebot/db';
import { env } from "@sourcebot/shared";
import { minidenticon } from 'minidenticons';

export const runtime = 'nodejs';
export const alt = 'Sourcebot Chat';
Expand Down Expand Up @@ -37,6 +38,7 @@ export default async function Image({ params }: ImageProps) {
createdBy: {
select: {
name: true,
email: true,
image: true,
},
},
Expand All @@ -53,7 +55,9 @@ export default async function Image({ params }: ImageProps) {
const chatName = rawChatName.length > MAX_CHAT_NAME_LENGTH
? rawChatName.substring(0, MAX_CHAT_NAME_LENGTH).trim() + '...'
: rawChatName;
const creatorImage = chat.createdBy?.image;
const creatorEmail = chat.createdBy?.email;
const creatorImage = chat.createdBy?.image
?? (creatorEmail ? 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(creatorEmail, 50, 50)) : undefined);

return new ImageResponse(
(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@
import { searchChatShareableMembers } from "@/app/api/(client)/client";
import { SearchChatShareableMembersResponse } from "@/app/api/(server)/ee/chat/[chatId]/searchMembers/route";
import { SessionUser } from "@/auth";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LoadingButton } from "@/components/ui/loading-button";
import { Separator } from "@/components/ui/separator";
import { unwrapServiceError } from "@/lib/utils";
import placeholderAvatar from "@/public/placeholder_avatar.png";
import { UserAvatar } from "@/components/userAvatar";
import { useQuery } from "@tanstack/react-query";
import { useDebounce } from "@uidotdev/usehooks";
import { ChevronLeft, Circle, CircleCheck, Loader2, X } from "lucide-react";
Expand All @@ -33,17 +32,6 @@ export const InvitePanel = ({
const resultsRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);

const getInitials = (name?: string, email?: string) => {
if (name) {
return name.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2);
}
if (email) {
return email[0].toUpperCase();
}
return '?';
};


const debouncedSearchQuery = useDebounce(searchQuery, 100);

const { data: searchResults, isPending, isError } = useQuery<SearchChatShareableMembersResponse>({
Expand Down Expand Up @@ -157,10 +145,11 @@ export const InvitePanel = ({
) : (
<Circle className="h-5 w-5 text-muted-foreground shrink-0" />
)}
<Avatar className="h-8 w-8 ml-2">
<AvatarImage src={user.image ?? placeholderAvatar.src} />
<AvatarFallback>{getInitials(user.name ?? undefined, user.email ?? undefined)}</AvatarFallback>
</Avatar>
<UserAvatar
email={user.email}
imageUrl={user.image}
className="h-8 w-8 ml-2"
/>
<div className="flex flex-col items-start ml-1">
<span className="text-sm font-medium">{user.name || user.email}</span>
{user.name && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { SessionUser } from "@/auth";
import { useToast } from "@/components/hooks/use-toast";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Select,
Expand All @@ -13,7 +12,7 @@ import {
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import placeholderAvatar from "@/public/placeholder_avatar.png";
import { UserAvatar } from "@/components/userAvatar";
import { ChatVisibility } from "@sourcebot/db";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Info, Link2Icon, Loader2, Lock, X } from "lucide-react";
Expand Down Expand Up @@ -69,16 +68,6 @@ export const ShareSettings = ({
}
}, [chatId, visibility, toast]);

const getInitials = (name?: string | null, email?: string | null) => {
if (name) {
return name.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2);
}
if (email) {
return email[0].toUpperCase();
}
return '?';
};

return (
<div className="flex flex-col py-3 px-4">
<p className="text-sm font-medium">Share</p>
Expand Down Expand Up @@ -113,10 +102,11 @@ export const ShareSettings = ({
{currentUser && (
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={currentUser.image ?? placeholderAvatar.src} />
<AvatarFallback>{getInitials(currentUser.name, currentUser.email)}</AvatarFallback>
</Avatar>
<UserAvatar
email={currentUser.email}
imageUrl={currentUser.image}
className="h-8 w-8"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{currentUser.name || currentUser.email}
Expand All @@ -134,10 +124,11 @@ export const ShareSettings = ({
{sharedWithUsers.map((user) => (
<div key={user.id} className="flex items-center justify-between py-2">
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={user.image ?? placeholderAvatar.src} />
<AvatarFallback>{getInitials(user.name, user.email)}</AvatarFallback>
</Avatar>
<UserAvatar
email={user.email}
imageUrl={user.image}
className="h-8 w-8"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{user.name || user.email}</span>
{user.name && (
Expand Down
27 changes: 11 additions & 16 deletions packages/web/src/app/[domain]/components/meControlDropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { signOut } from "next-auth/react"
import posthog from "posthog-js";
import { useDomain } from "@/hooks/useDomain";
import { Session } from "next-auth";
import { AppearanceDropdownMenuGroup } from "./appearanceDropdownMenuGroup";
import placeholderAvatar from "@/public/placeholder_avatar.png";
import { UserAvatar } from "@/components/userAvatar";

interface MeControlDropdownMenuProps {
menuButtonClassName?: string;
Expand All @@ -35,24 +34,20 @@ export const MeControlDropdownMenu = ({
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className={cn("h-8 w-8 cursor-pointer", menuButtonClassName)}>
<AvatarImage src={session.user.image ?? placeholderAvatar.src} />
<AvatarFallback className="bg-primary/10 text-primary font-semibold text-sm">
{session.user.name && session.user.name.length > 0 ? session.user.name[0].toUpperCase() : 'U'}
</AvatarFallback>
</Avatar>
<UserAvatar
email={session.user.email}
imageUrl={session.user.image}
className={cn("h-8 w-8 cursor-pointer", menuButtonClassName)}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-64" align="end" sideOffset={5}>
<DropdownMenuGroup>
<div className="flex flex-row items-center gap-3 px-3 py-3">
<Avatar className="h-10 w-10 flex-shrink-0">
<AvatarImage
src={session.user.image ?? placeholderAvatar.src}
/>
<AvatarFallback className="bg-primary/10 text-primary font-semibold">
{session.user.name && session.user.name.length > 0 ? session.user.name[0].toUpperCase() : 'U'}
</AvatarFallback>
</Avatar>
<UserAvatar
email={session.user.email}
imageUrl={session.user.image}
className="h-10 w-10 flex-shrink-0"
/>
<div className="flex flex-col flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{session.user.name ?? "User"}</p>
{session.user.email && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@
import { OrgRole } from "@sourcebot/db";
import { useToast } from "@/components/hooks/use-toast";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { createPathWithQueryParams, isServiceError } from "@/lib/utils";
import placeholderAvatar from "@/public/placeholder_avatar.png";
import { UserAvatar } from "@/components/userAvatar";
import { Copy, MoreVertical, Search } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { cancelInvite } from "@/actions";
Expand Down Expand Up @@ -107,9 +106,7 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
filteredInvites.map((invite) => (
<div key={invite.id} className="p-4 flex items-center justify-between bg-background">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={placeholderAvatar.src} />
</Avatar>
<UserAvatar email={invite.email} />
<div>
<div className="text-sm text-muted-foreground">{invite.email}</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
import { Input } from "@/components/ui/input";
import { Search, MoreVertical } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useCallback, useMemo, useState } from "react";
import { OrgRole } from "@prisma/client";
import placeholderAvatar from "@/public/placeholder_avatar.png";
import { UserAvatar } from "@/components/userAvatar";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { promoteToOwner, demoteToMember } from "@/ee/features/userManagement/actions";
import { leaveOrg, removeMemberFromOrg } from "@/features/userManagement/actions";
Expand Down Expand Up @@ -200,9 +199,10 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName,
filteredMembers.map((member) => (
<div key={member.id} className="p-4 flex items-center justify-between bg-background">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={member.avatarUrl ?? placeholderAvatar.src} />
</Avatar>
<UserAvatar
email={member.email}
imageUrl={member.avatarUrl}
/>
<div>
<div className="font-medium">{member.name}</div>
<div className="text-sm text-muted-foreground">{member.email}</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
import { OrgRole } from "@sourcebot/db";
import { useToast } from "@/components/hooks/use-toast";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { isServiceError } from "@/lib/utils";
import placeholderAvatar from "@/public/placeholder_avatar.png";
import { UserAvatar } from "@/components/userAvatar";
import { CheckCircle, Search, XCircle } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { approveAccountRequest, rejectAccountRequest } from "@/actions";
Expand All @@ -20,6 +19,7 @@ interface Request {
email: string;
createdAt: Date;
name?: string;
image?: string;
}

interface RequestsListProps {
Expand Down Expand Up @@ -130,9 +130,7 @@ export const RequestsList = ({ requests, currentUserRole }: RequestsListProps) =
filteredRequests.map((request) => (
<div key={request.id} className="p-4 flex items-center justify-between bg-background">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={placeholderAvatar.src} />
</Avatar>
<UserAvatar email={request.email} imageUrl={request.image} />
<div>
<div className="font-medium">{request.name || request.email}</div>
<div className="text-sm text-muted-foreground">{request.email}</div>
Expand Down
29 changes: 29 additions & 0 deletions packages/web/src/app/api/minidenticon/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use server';

import { minidenticon } from 'minidenticons';
import sharp from 'sharp';
import { NextRequest } from 'next/server';
import { apiHandler } from '@/lib/apiHandler';

// Generates a minidenticon avatar PNG from an email address.
// Used as a fallback avatar in emails where data URIs aren't supported.
export const GET = apiHandler(async (request: NextRequest) => {
const email = request.nextUrl.searchParams.get('email');
if (!email) {
return new Response('Missing email parameter', { status: 400 });
}

const svg = minidenticon(email, 50, 50);
const png = await sharp(Buffer.from(svg))
.flatten({ background: { r: 241, g: 245, b: 249 } })
.resize(128, 128)
.png()
.toBuffer();

return new Response(new Uint8Array(png), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
}, { track: false });
Loading
Loading