fix: add missing @ prefix for federated usernames in profile cards
Add showAt prop to Username component and use it in both UserProfilePopout and UserProfileModal to consistently display @username for all users. Also move avatar ring styling into Avatar component's ring prop.
This commit is contained in:
@@ -255,18 +255,15 @@ export function UserProfileModal() {
|
|||||||
|
|
||||||
{/* Header (avatar + name) */}
|
{/* Header (avatar + name) */}
|
||||||
<div className="px-5 flex-shrink-0 relative">
|
<div className="px-5 flex-shrink-0 relative">
|
||||||
<div
|
<Avatar
|
||||||
className="mt-[-48px] mb-2 w-fit rounded-full"
|
src={user.avatar}
|
||||||
style={{ border: '4px solid var(--color-surface-elevated, #1e1e2a)' }}
|
name={displayName}
|
||||||
>
|
size={96}
|
||||||
<Avatar
|
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
||||||
src={user.avatar}
|
userId={user.homeUserId ?? user.id}
|
||||||
name={displayName}
|
ring={{ width: 4, color: 'var(--color-surface-elevated, #1e1e2a)' }}
|
||||||
size={96}
|
className="mt-[-52px] mb-2"
|
||||||
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
/>
|
||||||
userId={user.homeUserId ?? user.id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<Username
|
<Username
|
||||||
@@ -274,11 +271,7 @@ export function UserProfileModal() {
|
|||||||
className="text-[20px] font-bold leading-tight"
|
className="text-[20px] font-bold leading-tight"
|
||||||
/>
|
/>
|
||||||
<div className="text-[14px] text-txt-tertiary mt-0.5">
|
<div className="text-[14px] text-txt-tertiary mt-0.5">
|
||||||
{domain ? (
|
<Username username={user.username} showAt className="text-[14px] text-txt-tertiary" />
|
||||||
<Username username={user.username} className="text-[14px] text-txt-tertiary" />
|
|
||||||
) : (
|
|
||||||
<span>@{baseName}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{user.customStatus && (
|
{user.customStatus && (
|
||||||
<div className="text-[13px] text-txt-secondary italic mt-1">
|
<div className="text-[13px] text-txt-secondary italic mt-1">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface AvatarProps {
|
|||||||
onClick?: (e: React.MouseEvent) => void;
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
user?: User;
|
user?: User;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
ring?: { width: number; color: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
@@ -29,27 +30,40 @@ const statusColors: Record<string, string> = {
|
|||||||
* Prototype reference (.m-dot): 12px box with 3px border (border-box) = 6px
|
* Prototype reference (.m-dot): 12px box with 3px border (border-box) = 6px
|
||||||
* visible color, positioned at bottom:-2 right:-2 → center 4px from corner.
|
* visible color, positioned at bottom:-2 right:-2 → center 4px from corner.
|
||||||
*/
|
*/
|
||||||
function buildCutoutMask(size: number): string {
|
function buildCutoutMask(avatarSize: number, ringWidth: number = 0): string {
|
||||||
const { dot, gap, inset } = getDotMetrics(size);
|
const outerSize = avatarSize + ringWidth * 2;
|
||||||
const cx = size - inset;
|
const { dot, gap, inset } = getDotMetrics(avatarSize, ringWidth);
|
||||||
const cy = size - inset;
|
const cx = outerSize - inset;
|
||||||
|
const cy = outerSize - inset;
|
||||||
const r = dot / 2 + gap;
|
const r = dot / 2 + gap;
|
||||||
return `radial-gradient(circle at ${cx}px ${cy}px, transparent ${r}px, black ${r + 0.5}px)`;
|
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. */
|
/** Returns visible dot diameter, gap width, and center inset from outer edge. */
|
||||||
function getDotMetrics(size: number) {
|
function getDotMetrics(avatarSize: number, ringWidth: number = 0) {
|
||||||
if (size <= 24) return { dot: 5, gap: 2, inset: 3 };
|
let dot: number, gap: number, avatarInset: number;
|
||||||
return { dot: 6, gap: 3, inset: 4 };
|
if (avatarSize <= 24) {
|
||||||
|
dot = 5; gap = 2; avatarInset = 3;
|
||||||
|
} else if (avatarSize <= 48) {
|
||||||
|
dot = 6; gap = 3; avatarInset = 4;
|
||||||
|
} else {
|
||||||
|
dot = Math.round(avatarSize * 0.15);
|
||||||
|
gap = Math.round(avatarSize * 0.05);
|
||||||
|
avatarInset = Math.round(avatarSize * 0.10);
|
||||||
|
}
|
||||||
|
return { dot, gap, inset: avatarInset + ringWidth };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId }: AvatarProps) {
|
export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId, ring }: AvatarProps) {
|
||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
const initials = name.charAt(0).toUpperCase();
|
const initials = name.charAt(0).toUpperCase();
|
||||||
// Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px
|
// Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px
|
||||||
const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18;
|
const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18;
|
||||||
const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, user?.avatarColor);
|
const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, user?.avatarColor);
|
||||||
|
|
||||||
|
const ringWidth = ring?.width ?? 0;
|
||||||
|
const outerSize = size + ringWidth * 2;
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick(e);
|
onClick(e);
|
||||||
@@ -64,41 +78,52 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Only compute mask when status dot is visible
|
// Only compute mask when status dot is visible
|
||||||
const cutoutMask = status ? buildCutoutMask(size) : undefined;
|
const cutoutMask = status ? buildCutoutMask(size, ringWidth) : undefined;
|
||||||
const maskStyle: React.CSSProperties | undefined = cutoutMask
|
const maskStyle: React.CSSProperties | undefined = cutoutMask
|
||||||
? { maskImage: cutoutMask, WebkitMaskImage: cutoutMask }
|
? { maskImage: cutoutMask, WebkitMaskImage: cutoutMask }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const { dot: dotDiameter, inset: dotInset } = getDotMetrics(size);
|
const { dot: dotDiameter, inset: dotInset } = getDotMetrics(size, ringWidth);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative inline-flex flex-shrink-0 ${(onClick || user) ? 'cursor-pointer' : ''} ${className}`}
|
className={`relative inline-flex flex-shrink-0 ${(onClick || user) ? 'cursor-pointer' : ''} ${className}`}
|
||||||
style={{ width: size, height: size }}
|
style={{ width: outerSize, height: outerSize }}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{src ? (
|
{/* Inner masked circle — ring background + avatar content */}
|
||||||
<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
|
<div
|
||||||
className={`avatar-fallback w-full h-full rounded-full flex items-center justify-center font-bold text-white ${src ? 'hidden' : 'flex'}`}
|
className="w-full h-full rounded-full"
|
||||||
style={src ? { display: 'none' } : { background: gradient.gradient, fontSize: fontPx, ...maskStyle }}
|
style={{
|
||||||
|
padding: ringWidth,
|
||||||
|
backgroundColor: ring?.color,
|
||||||
|
...maskStyle,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{initials}
|
{src ? (
|
||||||
|
<img
|
||||||
|
src={(src.startsWith('http') || src.startsWith('blob:') || src.startsWith('data:'))
|
||||||
|
? src : `/api/uploads/${src}`}
|
||||||
|
alt={name}
|
||||||
|
className="w-full h-full rounded-full object-cover"
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Status dot — outside the masked div so it isn't clipped */}
|
||||||
{status && (
|
{status && (
|
||||||
<div
|
<div
|
||||||
className={`absolute rounded-full ${statusColors[status] ?? 'bg-status-offline'}`}
|
className={`absolute rounded-full ${statusColors[status] ?? 'bg-status-offline'}`}
|
||||||
|
|||||||
@@ -102,18 +102,15 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-4 pb-4 relative">
|
<div className="px-4 pb-4 relative">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div
|
<Avatar
|
||||||
className="mt-[-40px] mb-3 w-fit rounded-full"
|
src={user.avatar}
|
||||||
style={{ border: '4px solid rgba(20,20,26,0.85)' }}
|
name={displayName}
|
||||||
>
|
size={80}
|
||||||
<Avatar
|
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
||||||
src={user.avatar}
|
userId={user.homeUserId ?? user.id}
|
||||||
name={displayName}
|
ring={{ width: 4, color: 'rgba(20,20,26,0.85)' }}
|
||||||
size={80}
|
className="mt-[-44px] mb-3"
|
||||||
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
/>
|
||||||
userId={user.homeUserId ?? user.id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name & info */}
|
{/* Name & info */}
|
||||||
<div>
|
<div>
|
||||||
@@ -122,11 +119,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
|||||||
className="text-[16px] font-semibold leading-tight"
|
className="text-[16px] font-semibold leading-tight"
|
||||||
/>
|
/>
|
||||||
<div className="text-[13px] text-txt-tertiary">
|
<div className="text-[13px] text-txt-tertiary">
|
||||||
{domain ? (
|
<Username username={user.username} showAt className="text-[13px] text-txt-tertiary" />
|
||||||
<Username username={user.username} className="text-[13px] text-txt-tertiary" />
|
|
||||||
) : (
|
|
||||||
<span>@{baseName}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{user.customStatus && (
|
{user.customStatus && (
|
||||||
<div className="text-[13px] text-txt-secondary italic mt-1">
|
<div className="text-[13px] text-txt-secondary italic mt-1">
|
||||||
|
|||||||
@@ -3,21 +3,23 @@ import { Tooltip } from './Tooltip';
|
|||||||
|
|
||||||
interface UsernameProps {
|
interface UsernameProps {
|
||||||
username: string;
|
username: string;
|
||||||
|
showAt?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Username({ username, className, style }: UsernameProps) {
|
export function Username({ username, showAt, className, style }: UsernameProps) {
|
||||||
const atIndex = username.indexOf('@');
|
const atIndex = username.indexOf('@');
|
||||||
|
const prefix = showAt ? '@' : '';
|
||||||
if (atIndex === -1) {
|
if (atIndex === -1) {
|
||||||
return <span className={className} style={style}>{username}</span>;
|
return <span className={className} style={style}>{prefix}{username}</span>;
|
||||||
}
|
}
|
||||||
const name = username.slice(0, atIndex);
|
const name = username.slice(0, atIndex);
|
||||||
const domain = username.slice(atIndex + 1);
|
const domain = username.slice(atIndex + 1);
|
||||||
return (
|
return (
|
||||||
<Tooltip content={username} position="top">
|
<Tooltip content={`${prefix}${username}`} position="top">
|
||||||
<span className={className} style={style}>
|
<span className={className} style={style}>
|
||||||
{name}
|
{prefix}{name}
|
||||||
<span className="text-txt-tertiary text-[0.8em] ml-0.5 font-normal">@{domain}</span>
|
<span className="text-txt-tertiary text-[0.8em] ml-0.5 font-normal">@{domain}</span>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
Reference in New Issue
Block a user