Use DiceBear bottts-neutral as the site-wide avatar fallback

Hop-State: A_06FP86GJ50CYTWZ5YZECQ0G
Hop-Proposal: R_06FP86FN4Y59N0Z8828K6W8
Hop-Task: T_06FP85JN26GSWT1GS5G0W20
Hop-Attempt: AT_06FP85JN25ZCGGGSSWN1CTR
This commit is contained in:
2026-07-14 21:47:38 -07:00
committed by Hop
parent c1647bbcfa
commit 270d5437f0
12 changed files with 81 additions and 97 deletions
+2 -5
View File
@@ -13,6 +13,7 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { ArrowLeftIcon } from '@/components/Icons'; import { ArrowLeftIcon } from '@/components/Icons';
import { Bot, Plus, Sparkles } from 'lucide-react'; import { Bot, Plus, Sparkles } from 'lucide-react';
import { AvatarImage } from '@/components/AvatarImage';
interface BotData { interface BotData {
id: string; id: string;
@@ -126,11 +127,7 @@ export default function BotsPage() {
fontSize: '18px', fontSize: '18px',
}} }}
> >
{bot.avatarUrl ? ( <AvatarImage avatarUrl={bot.avatarUrl} seed={bot.handle} alt={bot.name} />
<img src={bot.avatarUrl} alt={bot.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
) : (
bot.name.charAt(0).toUpperCase()
)}
</Link> </Link>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
+16 -11
View File
@@ -7,6 +7,7 @@ import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVert
import Link from 'next/link'; import Link from 'next/link';
import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle'; import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { AvatarImage } from '@/components/AvatarImage';
interface Conversation { interface Conversation {
id: string; id: string;
@@ -386,11 +387,11 @@ export default function ChatPage() {
<ArrowLeft size={20} /> <ArrowLeft size={20} />
</button> </button>
<div className="avatar" style={{ width: '32px', height: '32px', fontSize: '14px' }}> <div className="avatar" style={{ width: '32px', height: '32px', fontSize: '14px' }}>
{selectedConversation.participant2.avatarUrl ? ( <AvatarImage
<img src={selectedConversation.participant2.avatarUrl} alt="" /> avatarUrl={selectedConversation.participant2.avatarUrl}
) : ( seed={selectedConversation.participant2.handle}
selectedConversation.participant2.displayName[0] || '?' alt={selectedConversation.participant2.displayName || selectedConversation.participant2.handle}
)} />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Link <Link
@@ -439,11 +440,11 @@ export default function ChatPage() {
flexDirection: msg.isSentByMe ? 'row-reverse' : 'row' flexDirection: msg.isSentByMe ? 'row-reverse' : 'row'
}}> }}>
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}> <div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
{msg.isSentByMe ? ( <AvatarImage
user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : user.displayName[0] avatarUrl={msg.isSentByMe ? user.avatarUrl : msg.senderAvatarUrl}
) : ( seed={msg.isSentByMe ? user.handle : msg.senderHandle}
msg.senderAvatarUrl ? <img src={msg.senderAvatarUrl} alt="" /> : msg.senderDisplayName?.[0] alt={msg.isSentByMe ? user.displayName : msg.senderDisplayName || msg.senderHandle}
)} />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: msg.isSentByMe ? 'flex-end' : 'flex-start' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: msg.isSentByMe ? 'flex-end' : 'flex-start' }}>
@@ -595,7 +596,11 @@ export default function ChatPage() {
style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px' }} style={{ cursor: 'pointer', display: 'flex', alignItems: 'flex-start', gap: '12px' }}
> >
<div className="avatar"> <div className="avatar">
{conv.participant2.avatarUrl ? <img src={conv.participant2.avatarUrl} alt="" /> : conv.participant2.displayName?.[0] || '?'} <AvatarImage
avatarUrl={conv.participant2.avatarUrl}
seed={conv.participant2.handle}
alt={conv.participant2.displayName || conv.participant2.handle}
/>
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
+2 -5
View File
@@ -9,6 +9,7 @@ import { useFormattedHandle } from '@/lib/utils/handle';
import { Bot, Network, Server, EyeOff } from 'lucide-react'; import { Bot, Network, Server, EyeOff } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { signedAPI } from '@/lib/api/signed-fetch'; import { signedAPI } from '@/lib/api/signed-fetch';
import { AvatarImage } from '@/components/AvatarImage';
interface User { interface User {
id: string; id: string;
@@ -26,11 +27,7 @@ function UserCard({ user }: { user: User }) {
return ( return (
<Link href={`/u/${user.handle}`} className="user-card"> <Link href={`/u/${user.handle}`} className="user-card">
<div className="avatar"> <div className="avatar">
{user.avatarUrl ? ( <AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
<img src={user.avatarUrl} alt={user.displayName} />
) : (
user.displayName?.charAt(0).toUpperCase() || user.handle.charAt(0).toUpperCase()
)}
</div> </div>
<div className="user-card-info"> <div className="user-card-info">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
+4 -26
View File
@@ -5,6 +5,7 @@ import { BellIcon } from '@/components/Icons';
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { getProfilePath } from '@/lib/utils/handle'; import { getProfilePath } from '@/lib/utils/handle';
import { AvatarImage } from '@/components/AvatarImage';
interface NotificationActor { interface NotificationActor {
id: string; id: string;
@@ -218,32 +219,9 @@ function NotificationItem({
}} }}
> >
<Link href={actorProfilePath} style={{ flexShrink: 0 }}> <Link href={actorProfilePath} style={{ flexShrink: 0 }}>
{actor?.avatarUrl ? ( <div className="avatar">
<img <AvatarImage avatarUrl={actor?.avatarUrl} seed={actor?.handle || 'unknown'} alt={actor?.displayName || actor?.handle || 'Unknown user'} />
src={actor.avatarUrl} </div>
alt={actor.displayName || actor.handle}
width={40}
height={40}
style={{ borderRadius: '50%', objectFit: 'cover' }}
/>
) : (
<div
style={{
width: 40,
height: 40,
borderRadius: '50%',
background: 'var(--background-tertiary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--foreground-secondary)',
fontSize: '16px',
fontWeight: 600,
}}
>
{(actor?.displayName || actor?.handle || '?')[0].toUpperCase()}
</div>
)}
</Link> </Link>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
+2 -5
View File
@@ -9,6 +9,7 @@ import { Post } from '@/lib/types';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { signedAPI } from '@/lib/api/signed-fetch'; import { signedAPI } from '@/lib/api/signed-fetch';
import { AvatarImage } from '@/components/AvatarImage';
interface User { interface User {
id: string; id: string;
@@ -82,11 +83,7 @@ function UserCard({ user }: { user: User }) {
className="hover-bg" className="hover-bg"
> >
<div className="avatar"> <div className="avatar">
{user.avatarUrl ? ( <AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
<img src={user.avatarUrl} alt={user.displayName} />
) : (
(user.displayName || user.handle).charAt(0).toUpperCase()
)}
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
+4 -10
View File
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { getProfilePath } from '@/lib/utils/handle'; import { getProfilePath } from '@/lib/utils/handle';
import { ArrowLeftIcon } from '@/components/Icons'; import { ArrowLeftIcon } from '@/components/Icons';
import { UserX, Globe, Trash2 } from 'lucide-react'; import { UserX, Globe, Trash2 } from 'lucide-react';
import { AvatarImage } from '@/components/AvatarImage';
interface BlockedUser { interface BlockedUser {
id: string; id: string;
@@ -189,16 +190,9 @@ export default function ModerationSettingsPage() {
textDecoration: 'none', textDecoration: 'none',
}} }}
> >
<img <div className="avatar">
src={user.avatarUrl || '/default-avatar.png'} <AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
alt="" </div>
style={{
width: '40px',
height: '40px',
borderRadius: '50%',
objectFit: 'cover',
}}
/>
<div> <div>
<div style={{ fontWeight: 500 }}> <div style={{ fontWeight: 500 }}>
{user.displayName || user.handle} {user.displayName || user.handle}
+3 -10
View File
@@ -13,6 +13,7 @@ import { useFormattedHandle } from '@/lib/utils/handle';
import { Bot } from 'lucide-react'; import { Bot } from 'lucide-react';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { signedAPI } from '@/lib/api/signed-fetch'; import { signedAPI } from '@/lib/api/signed-fetch';
import { AvatarImage } from '@/components/AvatarImage';
interface BotOwner { interface BotOwner {
id: string; id: string;
@@ -41,11 +42,7 @@ function UserRow({ user }: { user: UserSummary }) {
return ( return (
<Link href={`/u/${user.handle}`} className="user-row"> <Link href={`/u/${user.handle}`} className="user-row">
<div className="avatar"> <div className="avatar">
{user.avatarUrl ? ( <AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
) : (
(user.displayName || user.handle).charAt(0).toUpperCase()
)}
</div> </div>
<div className="user-row-content"> <div className="user-row-content">
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
@@ -568,11 +565,7 @@ export default function ProfilePage() {
position: 'relative', position: 'relative',
}} }}
> >
{(isEditing ? profileForm.avatarUrl : user.avatarUrl) ? ( <AvatarImage avatarUrl={isEditing ? profileForm.avatarUrl : user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
<img src={(isEditing ? profileForm.avatarUrl : user.avatarUrl) || ''} alt={user.displayName || user.handle} />
) : (
(user.displayName || user.handle).charAt(0).toUpperCase()
)}
</div> </div>
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}> <div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
+10
View File
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest';
import { getDiceBearAvatarUrl } from './AvatarImage';
describe('getDiceBearAvatarUrl', () => {
it('uses the site-wide bottts-neutral style and a stable encoded handle seed', () => {
expect(getDiceBearAvatarUrl('cyph3r@node.example')).toBe(
'https://api.dicebear.com/9.x/bottts-neutral/svg?seed=cyph3r%40node.example',
);
});
});
+30
View File
@@ -0,0 +1,30 @@
'use client';
import { useState, type ImgHTMLAttributes } from 'react';
export function getDiceBearAvatarUrl(seed: string): string {
return `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${encodeURIComponent(seed)}`;
}
interface AvatarImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
avatarUrl?: string | null;
seed: string;
}
export function AvatarImage({ avatarUrl, seed, alt = '', onError, ...props }: AvatarImageProps) {
const [failedAvatarUrl, setFailedAvatarUrl] = useState<string | null>(null);
const customAvatar = avatarUrl?.trim();
const src = customAvatar && failedAvatarUrl !== customAvatar ? customAvatar : getDiceBearAvatarUrl(seed);
return (
<img
{...props}
src={src}
alt={alt}
onError={(event) => {
onError?.(event);
if (customAvatar && failedAvatarUrl !== customAvatar) setFailedAvatarUrl(customAvatar);
}}
/>
);
}
+3 -10
View File
@@ -14,6 +14,7 @@ import { useFormattedHandle } from '@/lib/utils/handle';
import { useDomain } from '@/lib/contexts/ConfigContext'; import { useDomain } from '@/lib/contexts/ConfigContext';
import { signedAPI } from '@/lib/api/signed-fetch'; import { signedAPI } from '@/lib/api/signed-fetch';
import type { LinkPreviewData } from '@/lib/media/linkPreview'; import type { LinkPreviewData } from '@/lib/media/linkPreview';
import { AvatarImage } from '@/components/AvatarImage';
// Component for link preview image that hides on error // Component for link preview image that hides on error
function LinkPreviewImage({ src, alt }: { src: string; alt: string }) { function LinkPreviewImage({ src, alt }: { src: string; alt: string }) {
@@ -638,11 +639,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
<div className="post-header"> <div className="post-header">
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}> <Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
<div className="avatar"> <div className="avatar">
{post.author.avatarUrl ? ( <AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} alt={post.author.displayName || post.author.handle} />
<img src={post.author.avatarUrl} alt={post.author.displayName || ''} />
) : (
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
)}
</div> </div>
</Link> </Link>
<div className="post-author"> <div className="post-author">
@@ -720,11 +717,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
<div className="post-header"> <div className="post-header">
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}> <Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
<div className="avatar"> <div className="avatar">
{post.author.avatarUrl ? ( <AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} alt={post.author.displayName || post.author.handle} />
<img src={post.author.avatarUrl} alt={post.author.displayName} />
) : (
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
)}
</div> </div>
</Link> </Link>
<div className="post-author"> <div className="post-author">
+2 -5
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { AvatarImage } from './AvatarImage';
interface Admin { interface Admin {
handle: string; handle: string;
@@ -136,11 +137,7 @@ export function RightSidebar() {
style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }} style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }}
> >
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}> <div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
{admin.avatarUrl ? ( <AvatarImage avatarUrl={admin.avatarUrl} seed={admin.handle} alt={admin.displayName || admin.handle} />
<img src={admin.avatarUrl} alt={admin.displayName || admin.handle} />
) : (
(admin.displayName?.charAt(0) || admin.handle.charAt(0)).toUpperCase()
)}
</div> </div>
<div> <div>
<div style={{ fontWeight: 500, fontSize: '14px' }}> <div style={{ fontWeight: 500, fontSize: '14px' }}>
+3 -10
View File
@@ -10,6 +10,7 @@ import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, Bot
import { useFormattedHandle } from '@/lib/utils/handle'; import { useFormattedHandle } from '@/lib/utils/handle';
import { Check, ChevronDown, LogOut, Plus, Settings2 } from 'lucide-react'; import { Check, ChevronDown, LogOut, Plus, Settings2 } from 'lucide-react';
import { AuthScreen } from '@/app/login/page'; import { AuthScreen } from '@/app/login/page';
import { AvatarImage } from './AvatarImage';
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper // import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
function shortHandle(handle: string) { function shortHandle(handle: string) {
@@ -327,11 +328,7 @@ export function Sidebar() {
}} }}
> >
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}> <div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
{account.avatarUrl ? ( <AvatarImage avatarUrl={account.avatarUrl} seed={account.handle} alt={account.displayName || account.handle} />
<img src={account.avatarUrl} alt={account.displayName || account.handle} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
(account.displayName?.charAt(0) || account.handle.charAt(0)).toUpperCase()
)}
</div> </div>
<div style={{ minWidth: 0, flex: 1, display: 'grid', gap: '2px' }}> <div style={{ minWidth: 0, flex: 1, display: 'grid', gap: '2px' }}>
<div style={{ fontWeight: 700, fontSize: '15px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ fontWeight: 700, fontSize: '15px', lineHeight: 1.2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
@@ -418,11 +415,7 @@ export function Sidebar() {
}} }}
> >
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}> <div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
{user.avatarUrl ? ( <AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName} />
<img src={user.avatarUrl} alt={user.displayName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
(user.displayName?.charAt(0) || user.handle.charAt(0)).toUpperCase()
)}
</div> </div>
<div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}> <div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}>
<div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div> <div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div>