Files
backspace/packages/web/src/components/ui/Avatar.tsx
T
Jannis Braun c7c3331dbf fix: persist homeUserId for federated users to ensure consistent avatar colors
Store the original home snowflake ID (homeUserId) during federation replication
so that avatar gradient colors resolve identically across instances. Previously,
replicated users got new snowflake IDs on each instance, causing different
gradient colors. Now Avatar, UserProfilePopout, VoiceUser, StreamTile, and
VoiceChannel all resolve through homeUserId when available. Includes backfill
logic for existing federated users missing the field.
2026-03-04 13:45:25 +01:00

116 lines
4.0 KiB
TypeScript

import React from 'react';
import type { User } from '@backspace/shared';
import { useUIStore } from '../../stores/uiStore';
import { getAvatarGradient } from '../../utils/gradients';
interface AvatarProps {
src?: string | null;
name: string;
size?: number;
status?: 'online' | 'idle' | 'dnd' | 'offline' | null;
className?: string;
onClick?: (e: React.MouseEvent) => void;
user?: User;
userId?: string;
}
const statusColors: Record<string, string> = {
online: 'bg-status-online',
idle: 'bg-status-idle',
dnd: 'bg-status-dnd',
offline: 'bg-status-offline',
};
/**
* Builds a CSS radial-gradient mask that punches a circular hole in the avatar
* where the status dot sits. The hole reveals the parent background, creating
* a true cutout effect on any surface — no border-color matching needed.
*
* Prototype reference (.m-dot): 12px box with 3px border (border-box) = 6px
* visible color, positioned at bottom:-2 right:-2 → center 4px from corner.
*/
function buildCutoutMask(size: number): string {
const { dot, gap, inset } = getDotMetrics(size);
const cx = size - inset;
const cy = size - inset;
const r = dot / 2 + gap;
return `radial-gradient(circle at ${cx}px ${cy}px, transparent ${r}px, black ${r + 0.5}px)`;
}
/** Returns visible dot diameter, gap width, and center inset from avatar edge. */
function getDotMetrics(size: number) {
if (size <= 24) return { dot: 5, gap: 2, inset: 3 };
return { dot: 6, gap: 3, inset: 4 };
}
export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId }: AvatarProps) {
const openUserProfile = useUIStore((s) => s.openUserProfile);
const initials = name.charAt(0).toUpperCase();
// Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px
const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18;
const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name);
const handleClick = (e: React.MouseEvent) => {
if (onClick) {
onClick(e);
} else if (user) {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
openUserProfile(user, {
top: Math.min(rect.top, window.innerHeight - 450),
left: rect.right + 16,
});
}
};
// Only compute mask when status dot is visible
const cutoutMask = status ? buildCutoutMask(size) : undefined;
const maskStyle: React.CSSProperties | undefined = cutoutMask
? { maskImage: cutoutMask, WebkitMaskImage: cutoutMask }
: undefined;
const { dot: dotDiameter, inset: dotInset } = getDotMetrics(size);
return (
<div
className={`relative inline-flex flex-shrink-0 ${(onClick || user) ? 'cursor-pointer' : ''} ${className}`}
style={{ width: size, height: size }}
onClick={handleClick}
>
{src ? (
<img
src={src.startsWith('http') ? src : `/api/uploads/${src}`}
alt={name}
className="w-full h-full rounded-full object-cover"
style={maskStyle}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {
const fallback = parent.querySelector('.avatar-fallback') as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}
}}
/>
) : null}
<div
className={`avatar-fallback w-full h-full rounded-full flex items-center justify-center font-bold text-white ${src ? 'hidden' : 'flex'}`}
style={src ? { display: 'none' } : { background: gradient.gradient, fontSize: fontPx, ...maskStyle }}
>
{initials}
</div>
{status && (
<div
className={`absolute rounded-full ${statusColors[status] ?? 'bg-status-offline'}`}
style={{
width: dotDiameter,
height: dotDiameter,
bottom: dotInset - dotDiameter / 2,
right: dotInset - dotDiameter / 2,
}}
/>
)}
</div>
);
}