🎭 refactor: Avatar Loading UX and Fix Initials Rendering Bugs (#9261)

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2025-08-25 18:06:00 +02:00 committed by GitHub
parent e559f0f4dc
commit 94426a3cae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 562 additions and 31 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/client",
"version": "0.2.6",
"version": "0.2.7",
"description": "React components for LibreChat",
"main": "dist/index.js",
"module": "dist/index.es.js",
@ -64,7 +64,9 @@
"react-hook-form": "^7.56.4",
"react-resizable-panels": "^3.0.2",
"react-textarea-autosize": "^8.4.0",
"tailwind-merge": "^1.9.1"
"tailwind-merge": "^1.9.1",
"@dicebear/core": "^9.2.2",
"@dicebear/collection": "^9.2.2"
},
"devDependencies": {
"@rollup/plugin-alias": "^5.1.0",

View file

@ -0,0 +1,102 @@
import React, { useState, useMemo, useCallback } from 'react';
import type { TUser } from 'librechat-data-provider';
import { Skeleton } from './Skeleton';
import { useAvatar } from '~/hooks';
import { UserIcon } from '~/svgs';
export interface AvatarProps {
user?: TUser;
size?: number;
className?: string;
alt?: string;
showDefaultWhenEmpty?: boolean;
}
const Avatar: React.FC<AvatarProps> = ({
user,
size = 32,
className = '',
alt,
showDefaultWhenEmpty = true,
}) => {
const avatarSrc = useAvatar(user);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
const avatarSeed = useMemo(
() => user?.avatar || user?.username || user?.email || '',
[user?.avatar, user?.username, user?.email],
);
const altText = useMemo(
() => alt || `${user?.name || user?.username || user?.email || ''}'s avatar`,
[alt, user?.name, user?.username, user?.email],
);
const imageSrc = useMemo(() => {
if (!avatarSeed || imageError) return '';
return (user?.avatar ?? '') || avatarSrc || '';
}, [user?.avatar, avatarSrc, avatarSeed, imageError]);
const handleImageLoad = useCallback(() => {
setImageLoaded(true);
}, []);
const handleImageError = useCallback(() => {
setImageError(true);
setImageLoaded(false);
}, []);
const DefaultAvatar = useCallback(
() => (
<div
style={{
backgroundColor: 'rgb(121, 137, 255)',
width: `${size}px`,
height: `${size}px`,
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
}}
className={`relative flex items-center justify-center rounded-full p-1 text-text-primary ${className}`}
aria-hidden="true"
>
<UserIcon />
</div>
),
[size, className],
);
if (avatarSeed.length === 0 && showDefaultWhenEmpty) {
return <DefaultAvatar />;
}
if (avatarSeed.length > 0 && !imageError) {
return (
<div className="relative" style={{ width: `${size}px`, height: `${size}px` }}>
{!imageLoaded && (
<Skeleton className="rounded-full" style={{ width: `${size}px`, height: `${size}px` }} />
)}
<img
style={{
width: `${size}px`,
height: `${size}px`,
display: imageLoaded ? 'block' : 'none',
}}
className={`rounded-full ${className}`}
src={imageSrc}
alt={altText}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
);
}
if (imageError && showDefaultWhenEmpty) {
return <DefaultAvatar />;
}
return null;
};
export default Avatar;

View file

@ -33,6 +33,7 @@ export * from './Resizable';
export * from './Select';
export { default as Radio } from './Radio';
export { default as Badge } from './Badge';
export { default as Avatar } from './Avatar';
export { default as Combobox } from './Combobox';
export { default as Dropdown } from './Dropdown';
export { default as SplitText } from './SplitText';

View file

@ -3,6 +3,7 @@
export type { TranslationKeys } from './useLocalize';
export { default as useToast } from './useToast';
export { default as useAvatar } from './useAvatar';
export { default as useCombobox } from './useCombobox';
export { default as useLocalize } from './useLocalize';
export { default as useMediaQuery } from './useMediaQuery';

View file

@ -0,0 +1,44 @@
import { useMemo } from 'react';
import { createAvatar } from '@dicebear/core';
import { initials } from '@dicebear/collection';
import type { TUser } from 'librechat-data-provider';
const avatarCache: Record<string, string> = {};
const useAvatar = (user: TUser | undefined) => {
return useMemo(() => {
const { username, name } = user ?? {};
const seed = name || username;
if (!seed) {
return '';
}
if (user?.avatar && user?.avatar !== '') {
return user.avatar;
}
if (avatarCache[seed]) {
return avatarCache[seed];
}
const avatar = createAvatar(initials, {
seed,
fontFamily: ['Verdana'],
fontSize: 36,
});
let avatarDataUri = '';
try {
avatarDataUri = avatar.toDataUri();
if (avatarDataUri) {
avatarCache[seed] = avatarDataUri;
}
} catch (error) {
console.error('Failed to generate avatar:', error);
}
return avatarDataUri;
}, [user]);
};
export default useAvatar;