Skip to content

Commit 85cf4be

Browse files
feat(web): replace placeholder avatars with minidenticon-based UserAvatar (#1072)
* feat(web): replace placeholder avatars with minidenticon-based UserAvatar component Adds the minidenticons library and a new UserAvatar component that generates deterministic avatar icons from email addresses. Replaces all placeholder avatar usage across chat, settings, and redeem pages with this unified component. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add CHANGELOG entry for minidenticon avatars (#1072) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(web): use placeholder avatar for org icon in accept invite card The org avatar should use the placeholder image, not a minidenticon generated from the org name. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(web): add /api/minidenticon endpoint for email avatar fallbacks Replace placeholder avatars in email templates with dynamically generated minidenticon PNGs. The new endpoint converts minidenticon SVGs to PNGs via sharp, making them compatible with email clients that don't support data URIs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * plumb avatar url for join requests --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b7ad9c2 commit 85cf4be

File tree

18 files changed

+538
-355
lines changed

18 files changed

+538
-355
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- Replaced placeholder avatars with deterministic minidenticon-based avatars generated from email addresses [#1072](https://github.com/sourcebot-dev/sourcebot/pull/1072)
12+
1013
## [4.16.4] - 2026-04-01
1114

1215
### Added

packages/web/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@
151151
"linguist-languages": "^9.3.1",
152152
"lucide-react": "^0.517.0",
153153
"micromatch": "^4.0.8",
154+
"minidenticons": "^4.2.1",
154155
"next": "16.1.6",
155156
"next-auth": "^5.0.0-beta.30",
156157
"next-navigation-guard": "^0.2.0",
@@ -195,7 +196,7 @@
195196
"devDependencies": {
196197
"@asteasolutions/zod-to-openapi": "7.3.4",
197198
"@eslint/eslintrc": "^3",
198-
"@react-email/preview-server": "5.2.8",
199+
"@react-email/preview-server": "5.2.10",
199200
"@react-grab/mcp": "^0.1.23",
200201
"@tanstack/eslint-plugin-query": "^5.74.7",
201202
"@testing-library/dom": "^10.4.1",
@@ -218,7 +219,7 @@
218219
"npm-run-all": "^4.1.5",
219220
"postcss": "^8",
220221
"raw-loader": "^4.0.2",
221-
"react-email": "^5.1.0",
222+
"react-email": "^5.2.10",
222223
"react-grab": "^0.1.23",
223224
"react-scan": "^0.5.3",
224225
"tailwindcss": "^3.4.1",

packages/web/src/actions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -723,6 +723,7 @@ export const createInvites = async (emails: string[]): Promise<{ success: boolea
723723
const inviteLink = `${env.AUTH_URL}/redeem?invite_id=${invite.id}`;
724724
const transport = createTransport(smtpConnectionUrl);
725725
const html = await render(InviteUserEmail({
726+
baseUrl: env.AUTH_URL,
726727
host: {
727728
name: user.name ?? undefined,
728729
email: user.email!,
@@ -999,6 +1000,7 @@ export const getOrgAccountRequests = async () => sew(() =>
9991000
email: request.requestedBy.email!,
10001001
createdAt: request.createdAt,
10011002
name: request.requestedBy.name ?? undefined,
1003+
image: request.requestedBy.image ?? undefined,
10021004
}));
10031005
}));
10041006

packages/web/src/app/[domain]/chat/[id]/opengraph-image.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { prisma } from '@/prisma';
44
import { getOrgFromDomain } from '@/data/org';
55
import { ChatVisibility } from '@sourcebot/db';
66
import { env } from "@sourcebot/shared";
7+
import { minidenticon } from 'minidenticons';
78

89
export const runtime = 'nodejs';
910
export const alt = 'Sourcebot Chat';
@@ -37,6 +38,7 @@ export default async function Image({ params }: ImageProps) {
3738
createdBy: {
3839
select: {
3940
name: true,
41+
email: true,
4042
image: true,
4143
},
4244
},
@@ -53,7 +55,9 @@ export default async function Image({ params }: ImageProps) {
5355
const chatName = rawChatName.length > MAX_CHAT_NAME_LENGTH
5456
? rawChatName.substring(0, MAX_CHAT_NAME_LENGTH).trim() + '...'
5557
: rawChatName;
56-
const creatorImage = chat.createdBy?.image;
58+
const creatorEmail = chat.createdBy?.email;
59+
const creatorImage = chat.createdBy?.image
60+
?? (creatorEmail ? 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(creatorEmail, 50, 50)) : undefined);
5761

5862
return new ImageResponse(
5963
(

packages/web/src/app/[domain]/chat/components/shareChatPopover/ee/invitePanel.tsx

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
import { searchChatShareableMembers } from "@/app/api/(client)/client";
44
import { SearchChatShareableMembersResponse } from "@/app/api/(server)/ee/chat/[chatId]/searchMembers/route";
55
import { SessionUser } from "@/auth";
6-
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
76
import { Badge } from "@/components/ui/badge";
87
import { Button } from "@/components/ui/button";
98
import { LoadingButton } from "@/components/ui/loading-button";
109
import { Separator } from "@/components/ui/separator";
1110
import { unwrapServiceError } from "@/lib/utils";
12-
import placeholderAvatar from "@/public/placeholder_avatar.png";
11+
import { UserAvatar } from "@/components/userAvatar";
1312
import { useQuery } from "@tanstack/react-query";
1413
import { useDebounce } from "@uidotdev/usehooks";
1514
import { ChevronLeft, Circle, CircleCheck, Loader2, X } from "lucide-react";
@@ -33,17 +32,6 @@ export const InvitePanel = ({
3332
const resultsRef = useRef<HTMLDivElement>(null);
3433
const inputRef = useRef<HTMLInputElement>(null);
3534

36-
const getInitials = (name?: string, email?: string) => {
37-
if (name) {
38-
return name.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2);
39-
}
40-
if (email) {
41-
return email[0].toUpperCase();
42-
}
43-
return '?';
44-
};
45-
46-
4735
const debouncedSearchQuery = useDebounce(searchQuery, 100);
4836

4937
const { data: searchResults, isPending, isError } = useQuery<SearchChatShareableMembersResponse>({
@@ -157,10 +145,11 @@ export const InvitePanel = ({
157145
) : (
158146
<Circle className="h-5 w-5 text-muted-foreground shrink-0" />
159147
)}
160-
<Avatar className="h-8 w-8 ml-2">
161-
<AvatarImage src={user.image ?? placeholderAvatar.src} />
162-
<AvatarFallback>{getInitials(user.name ?? undefined, user.email ?? undefined)}</AvatarFallback>
163-
</Avatar>
148+
<UserAvatar
149+
email={user.email}
150+
imageUrl={user.image}
151+
className="h-8 w-8 ml-2"
152+
/>
164153
<div className="flex flex-col items-start ml-1">
165154
<span className="text-sm font-medium">{user.name || user.email}</span>
166155
{user.name && (

packages/web/src/app/[domain]/chat/components/shareChatPopover/shareSettings.tsx

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { SessionUser } from "@/auth";
44
import { useToast } from "@/components/hooks/use-toast";
5-
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
65
import { Button } from "@/components/ui/button";
76
import {
87
Select,
@@ -13,7 +12,7 @@ import {
1312
} from "@/components/ui/select";
1413
import { Separator } from "@/components/ui/separator";
1514
import { cn } from "@/lib/utils";
16-
import placeholderAvatar from "@/public/placeholder_avatar.png";
15+
import { UserAvatar } from "@/components/userAvatar";
1716
import { ChatVisibility } from "@sourcebot/db";
1817
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
1918
import { Info, Link2Icon, Loader2, Lock, X } from "lucide-react";
@@ -69,16 +68,6 @@ export const ShareSettings = ({
6968
}
7069
}, [chatId, visibility, toast]);
7170

72-
const getInitials = (name?: string | null, email?: string | null) => {
73-
if (name) {
74-
return name.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2);
75-
}
76-
if (email) {
77-
return email[0].toUpperCase();
78-
}
79-
return '?';
80-
};
81-
8271
return (
8372
<div className="flex flex-col py-3 px-4">
8473
<p className="text-sm font-medium">Share</p>
@@ -113,10 +102,11 @@ export const ShareSettings = ({
113102
{currentUser && (
114103
<div className="flex items-center justify-between py-2">
115104
<div className="flex items-center gap-3">
116-
<Avatar className="h-8 w-8">
117-
<AvatarImage src={currentUser.image ?? placeholderAvatar.src} />
118-
<AvatarFallback>{getInitials(currentUser.name, currentUser.email)}</AvatarFallback>
119-
</Avatar>
105+
<UserAvatar
106+
email={currentUser.email}
107+
imageUrl={currentUser.image}
108+
className="h-8 w-8"
109+
/>
120110
<div className="flex flex-col">
121111
<span className="text-sm font-medium">
122112
{currentUser.name || currentUser.email}
@@ -134,10 +124,11 @@ export const ShareSettings = ({
134124
{sharedWithUsers.map((user) => (
135125
<div key={user.id} className="flex items-center justify-between py-2">
136126
<div className="flex items-center gap-3">
137-
<Avatar className="h-8 w-8">
138-
<AvatarImage src={user.image ?? placeholderAvatar.src} />
139-
<AvatarFallback>{getInitials(user.name, user.email)}</AvatarFallback>
140-
</Avatar>
127+
<UserAvatar
128+
email={user.email}
129+
imageUrl={user.image}
130+
className="h-8 w-8"
131+
/>
141132
<div className="flex flex-col">
142133
<span className="text-sm font-medium">{user.name || user.email}</span>
143134
{user.name && (

packages/web/src/app/[domain]/components/meControlDropdownMenu.tsx

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,12 @@ import {
1313
DropdownMenuTrigger,
1414
} from "@/components/ui/dropdown-menu"
1515
import { cn } from "@/lib/utils"
16-
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
1716
import { signOut } from "next-auth/react"
1817
import posthog from "posthog-js";
1918
import { useDomain } from "@/hooks/useDomain";
2019
import { Session } from "next-auth";
2120
import { AppearanceDropdownMenuGroup } from "./appearanceDropdownMenuGroup";
22-
import placeholderAvatar from "@/public/placeholder_avatar.png";
21+
import { UserAvatar } from "@/components/userAvatar";
2322

2423
interface MeControlDropdownMenuProps {
2524
menuButtonClassName?: string;
@@ -35,24 +34,20 @@ export const MeControlDropdownMenu = ({
3534
return (
3635
<DropdownMenu>
3736
<DropdownMenuTrigger asChild>
38-
<Avatar className={cn("h-8 w-8 cursor-pointer", menuButtonClassName)}>
39-
<AvatarImage src={session.user.image ?? placeholderAvatar.src} />
40-
<AvatarFallback className="bg-primary/10 text-primary font-semibold text-sm">
41-
{session.user.name && session.user.name.length > 0 ? session.user.name[0].toUpperCase() : 'U'}
42-
</AvatarFallback>
43-
</Avatar>
37+
<UserAvatar
38+
email={session.user.email}
39+
imageUrl={session.user.image}
40+
className={cn("h-8 w-8 cursor-pointer", menuButtonClassName)}
41+
/>
4442
</DropdownMenuTrigger>
4543
<DropdownMenuContent className="w-64" align="end" sideOffset={5}>
4644
<DropdownMenuGroup>
4745
<div className="flex flex-row items-center gap-3 px-3 py-3">
48-
<Avatar className="h-10 w-10 flex-shrink-0">
49-
<AvatarImage
50-
src={session.user.image ?? placeholderAvatar.src}
51-
/>
52-
<AvatarFallback className="bg-primary/10 text-primary font-semibold">
53-
{session.user.name && session.user.name.length > 0 ? session.user.name[0].toUpperCase() : 'U'}
54-
</AvatarFallback>
55-
</Avatar>
46+
<UserAvatar
47+
email={session.user.email}
48+
imageUrl={session.user.image}
49+
className="h-10 w-10 flex-shrink-0"
50+
/>
5651
<div className="flex flex-col flex-1 min-w-0">
5752
<p className="text-sm font-semibold truncate">{session.user.name ?? "User"}</p>
5853
{session.user.email && (

packages/web/src/app/[domain]/settings/members/components/invitesList.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
import { OrgRole } from "@sourcebot/db";
44
import { useToast } from "@/components/hooks/use-toast";
55
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
6-
import { Avatar, AvatarImage } from "@/components/ui/avatar";
76
import { Button } from "@/components/ui/button";
87
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
98
import { Input } from "@/components/ui/input";
109
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
1110
import { createPathWithQueryParams, isServiceError } from "@/lib/utils";
12-
import placeholderAvatar from "@/public/placeholder_avatar.png";
11+
import { UserAvatar } from "@/components/userAvatar";
1312
import { Copy, MoreVertical, Search } from "lucide-react";
1413
import { useCallback, useMemo, useState } from "react";
1514
import { cancelInvite } from "@/actions";
@@ -107,9 +106,7 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
107106
filteredInvites.map((invite) => (
108107
<div key={invite.id} className="p-4 flex items-center justify-between bg-background">
109108
<div className="flex items-center gap-3">
110-
<Avatar>
111-
<AvatarImage src={placeholderAvatar.src} />
112-
</Avatar>
109+
<UserAvatar email={invite.email} />
113110
<div>
114111
<div className="text-sm text-muted-foreground">{invite.email}</div>
115112
</div>

packages/web/src/app/[domain]/settings/members/components/membersList.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@
33
import { Input } from "@/components/ui/input";
44
import { Search, MoreVertical } from "lucide-react";
55
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
6-
import { Avatar, AvatarImage } from "@/components/ui/avatar";
76
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
87
import { Button } from "@/components/ui/button";
98
import { useCallback, useMemo, useState } from "react";
109
import { OrgRole } from "@prisma/client";
11-
import placeholderAvatar from "@/public/placeholder_avatar.png";
10+
import { UserAvatar } from "@/components/userAvatar";
1211
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
1312
import { promoteToOwner, demoteToMember } from "@/ee/features/userManagement/actions";
1413
import { leaveOrg, removeMemberFromOrg } from "@/features/userManagement/actions";
@@ -200,9 +199,10 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName,
200199
filteredMembers.map((member) => (
201200
<div key={member.id} className="p-4 flex items-center justify-between bg-background">
202201
<div className="flex items-center gap-3">
203-
<Avatar>
204-
<AvatarImage src={member.avatarUrl ?? placeholderAvatar.src} />
205-
</Avatar>
202+
<UserAvatar
203+
email={member.email}
204+
imageUrl={member.avatarUrl}
205+
/>
206206
<div>
207207
<div className="font-medium">{member.name}</div>
208208
<div className="text-sm text-muted-foreground">{member.email}</div>

packages/web/src/app/[domain]/settings/members/components/requestsList.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@
33
import { OrgRole } from "@sourcebot/db";
44
import { useToast } from "@/components/hooks/use-toast";
55
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
6-
import { Avatar, AvatarImage } from "@/components/ui/avatar";
76
import { Button } from "@/components/ui/button";
87
import { Input } from "@/components/ui/input";
98
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
109
import { isServiceError } from "@/lib/utils";
11-
import placeholderAvatar from "@/public/placeholder_avatar.png";
10+
import { UserAvatar } from "@/components/userAvatar";
1211
import { CheckCircle, Search, XCircle } from "lucide-react";
1312
import { useCallback, useMemo, useState } from "react";
1413
import { approveAccountRequest, rejectAccountRequest } from "@/actions";
@@ -20,6 +19,7 @@ interface Request {
2019
email: string;
2120
createdAt: Date;
2221
name?: string;
22+
image?: string;
2323
}
2424

2525
interface RequestsListProps {
@@ -130,9 +130,7 @@ export const RequestsList = ({ requests, currentUserRole }: RequestsListProps) =
130130
filteredRequests.map((request) => (
131131
<div key={request.id} className="p-4 flex items-center justify-between bg-background">
132132
<div className="flex items-center gap-3">
133-
<Avatar>
134-
<AvatarImage src={placeholderAvatar.src} />
135-
</Avatar>
133+
<UserAvatar email={request.email} imageUrl={request.image} />
136134
<div>
137135
<div className="font-medium">{request.name || request.email}</div>
138136
<div className="text-sm text-muted-foreground">{request.email}</div>

0 commit comments

Comments
 (0)