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:
@@ -13,6 +13,7 @@ import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { Bot, Plus, Sparkles } from 'lucide-react';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
|
||||
interface BotData {
|
||||
id: string;
|
||||
@@ -126,11 +127,7 @@ export default function BotsPage() {
|
||||
fontSize: '18px',
|
||||
}}
|
||||
>
|
||||
{bot.avatarUrl ? (
|
||||
<img src={bot.avatarUrl} alt={bot.name} style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '50%' }} />
|
||||
) : (
|
||||
bot.name.charAt(0).toUpperCase()
|
||||
)}
|
||||
<AvatarImage avatarUrl={bot.avatarUrl} seed={bot.handle} alt={bot.name} />
|
||||
</Link>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
|
||||
+16
-11
@@ -7,6 +7,7 @@ import { ArrowLeft, Send, Loader2, MessageCircle, Search, Plus, Trash2, MoreVert
|
||||
import Link from 'next/link';
|
||||
import { getProfilePath, useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
@@ -386,11 +387,11 @@ export default function ChatPage() {
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="avatar" style={{ width: '32px', height: '32px', fontSize: '14px' }}>
|
||||
{selectedConversation.participant2.avatarUrl ? (
|
||||
<img src={selectedConversation.participant2.avatarUrl} alt="" />
|
||||
) : (
|
||||
selectedConversation.participant2.displayName[0] || '?'
|
||||
)}
|
||||
<AvatarImage
|
||||
avatarUrl={selectedConversation.participant2.avatarUrl}
|
||||
seed={selectedConversation.participant2.handle}
|
||||
alt={selectedConversation.participant2.displayName || selectedConversation.participant2.handle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Link
|
||||
@@ -439,11 +440,11 @@ export default function ChatPage() {
|
||||
flexDirection: msg.isSentByMe ? 'row-reverse' : 'row'
|
||||
}}>
|
||||
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||
{msg.isSentByMe ? (
|
||||
user.avatarUrl ? <img src={user.avatarUrl} alt="" /> : user.displayName[0]
|
||||
) : (
|
||||
msg.senderAvatarUrl ? <img src={msg.senderAvatarUrl} alt="" /> : msg.senderDisplayName?.[0]
|
||||
)}
|
||||
<AvatarImage
|
||||
avatarUrl={msg.isSentByMe ? user.avatarUrl : msg.senderAvatarUrl}
|
||||
seed={msg.isSentByMe ? user.handle : msg.senderHandle}
|
||||
alt={msg.isSentByMe ? user.displayName : msg.senderDisplayName || msg.senderHandle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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' }}
|
||||
>
|
||||
<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 style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { Bot, Network, Server, EyeOff } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -26,11 +27,7 @@ function UserCard({ user }: { user: User }) {
|
||||
return (
|
||||
<Link href={`/u/${user.handle}`} className="user-card">
|
||||
<div className="avatar">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName} />
|
||||
) : (
|
||||
user.displayName?.charAt(0).toUpperCase() || user.handle.charAt(0).toUpperCase()
|
||||
)}
|
||||
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
|
||||
</div>
|
||||
<div className="user-card-info">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BellIcon } from '@/components/Icons';
|
||||
import Link from 'next/link';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { getProfilePath } from '@/lib/utils/handle';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
|
||||
interface NotificationActor {
|
||||
id: string;
|
||||
@@ -218,32 +219,9 @@ function NotificationItem({
|
||||
}}
|
||||
>
|
||||
<Link href={actorProfilePath} style={{ flexShrink: 0 }}>
|
||||
{actor?.avatarUrl ? (
|
||||
<img
|
||||
src={actor.avatarUrl}
|
||||
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>
|
||||
)}
|
||||
<div className="avatar">
|
||||
<AvatarImage avatarUrl={actor?.avatarUrl} seed={actor?.handle || 'unknown'} alt={actor?.displayName || actor?.handle || 'Unknown user'} />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Post } from '@/lib/types';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -82,11 +83,7 @@ function UserCard({ user }: { user: User }) {
|
||||
className="hover-bg"
|
||||
>
|
||||
<div className="avatar">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName} />
|
||||
) : (
|
||||
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||
)}
|
||||
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { getProfilePath } from '@/lib/utils/handle';
|
||||
import { ArrowLeftIcon } from '@/components/Icons';
|
||||
import { UserX, Globe, Trash2 } from 'lucide-react';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
|
||||
interface BlockedUser {
|
||||
id: string;
|
||||
@@ -189,16 +190,9 @@ export default function ModerationSettingsPage() {
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={user.avatarUrl || '/default-avatar.png'}
|
||||
alt=""
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<div className="avatar">
|
||||
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>
|
||||
{user.displayName || user.handle}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { Bot } from 'lucide-react';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
|
||||
interface BotOwner {
|
||||
id: string;
|
||||
@@ -41,11 +42,7 @@ function UserRow({ user }: { user: UserSummary }) {
|
||||
return (
|
||||
<Link href={`/u/${user.handle}`} className="user-row">
|
||||
<div className="avatar">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName || user.handle} />
|
||||
) : (
|
||||
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||
)}
|
||||
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
|
||||
</div>
|
||||
<div className="user-row-content">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
@@ -568,11 +565,7 @@ export default function ProfilePage() {
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{(isEditing ? profileForm.avatarUrl : user.avatarUrl) ? (
|
||||
<img src={(isEditing ? profileForm.avatarUrl : user.avatarUrl) || ''} alt={user.displayName || user.handle} />
|
||||
) : (
|
||||
(user.displayName || user.handle).charAt(0).toUpperCase()
|
||||
)}
|
||||
<AvatarImage avatarUrl={isEditing ? profileForm.avatarUrl : user.avatarUrl} seed={user.handle} alt={user.displayName || user.handle} />
|
||||
</div>
|
||||
|
||||
<div style={{ paddingTop: '12px', display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { useDomain } from '@/lib/contexts/ConfigContext';
|
||||
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||
import type { LinkPreviewData } from '@/lib/media/linkPreview';
|
||||
import { AvatarImage } from '@/components/AvatarImage';
|
||||
|
||||
// Component for link preview image that hides on error
|
||||
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">
|
||||
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="avatar">
|
||||
{post.author.avatarUrl ? (
|
||||
<img src={post.author.avatarUrl} alt={post.author.displayName || ''} />
|
||||
) : (
|
||||
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
|
||||
)}
|
||||
<AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} alt={post.author.displayName || post.author.handle} />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="post-author">
|
||||
@@ -720,11 +717,7 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
<div className="post-header">
|
||||
<Link href={`/u/${profileHandle}`} className="avatar-link" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="avatar">
|
||||
{post.author.avatarUrl ? (
|
||||
<img src={post.author.avatarUrl} alt={post.author.displayName} />
|
||||
) : (
|
||||
post.author.displayName?.charAt(0).toUpperCase() || post.author.handle.charAt(0).toUpperCase()
|
||||
)}
|
||||
<AvatarImage avatarUrl={post.author.avatarUrl} seed={post.author.handle} alt={post.author.displayName || post.author.handle} />
|
||||
</div>
|
||||
</Link>
|
||||
<div className="post-author">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { AvatarImage } from './AvatarImage';
|
||||
|
||||
interface Admin {
|
||||
handle: string;
|
||||
@@ -136,11 +137,7 @@ export function RightSidebar() {
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '10px', textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||
{admin.avatarUrl ? (
|
||||
<img src={admin.avatarUrl} alt={admin.displayName || admin.handle} />
|
||||
) : (
|
||||
(admin.displayName?.charAt(0) || admin.handle.charAt(0)).toUpperCase()
|
||||
)}
|
||||
<AvatarImage avatarUrl={admin.avatarUrl} seed={admin.handle} alt={admin.displayName || admin.handle} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: '14px' }}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { HomeIcon, SearchIcon, BellIcon, UserIcon, ShieldIcon, SettingsIcon, Bot
|
||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||
import { Check, ChevronDown, LogOut, Plus, Settings2 } from 'lucide-react';
|
||||
import { AuthScreen } from '@/app/login/page';
|
||||
import { AvatarImage } from './AvatarImage';
|
||||
// import { IdentityUnlockPrompt } from './IdentityUnlockPrompt'; // Moved to LayoutWrapper
|
||||
|
||||
function shortHandle(handle: string) {
|
||||
@@ -327,11 +328,7 @@ export function Sidebar() {
|
||||
}}
|
||||
>
|
||||
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||
{account.avatarUrl ? (
|
||||
<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()
|
||||
)}
|
||||
<AvatarImage avatarUrl={account.avatarUrl} seed={account.handle} alt={account.displayName || account.handle} />
|
||||
</div>
|
||||
<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' }}>
|
||||
@@ -418,11 +415,7 @@ export function Sidebar() {
|
||||
}}
|
||||
>
|
||||
<div className="avatar avatar-sm" style={{ flexShrink: 0 }}>
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.displayName} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
(user.displayName?.charAt(0) || user.handle.charAt(0)).toUpperCase()
|
||||
)}
|
||||
<AvatarImage avatarUrl={user.avatarUrl} seed={user.handle} alt={user.displayName} />
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1, textAlign: 'left' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.displayName}</div>
|
||||
|
||||
Reference in New Issue
Block a user