import { useEffect, useState } from 'react';
import type { AnyProfileView } from '@atcute/bluesky';
import { DisplayContext, getDisplayRestrictions, moderateProfile } from '@atcute/bluesky-moderation';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { clsx } from 'clsx';
import { useMaybeProfileShadow } from '#/state/cache/profile-shadow';
import { useModerationOpts } from '#/state/moderation/moderation-opts';
import { useSession } from '#/state/session';
import { UserAvatar } from '#/components/UserAvatar';
import PersonIcon from '#/icons/central/People_round_filled_radius1_stroke2.svg';
import * as css from './AvatarBubbles.css';
type Layout = {
border?: boolean;
/** index into `profiles`; slots are authored in paint order, which need not match profile order. */
index: number;
size: number;
x: number;
y: number;
};
type Props = {
animate?: boolean;
profiles: AnyProfileView[];
/** set to `true` to prevent filtering out the current user when there are more than two profiles */
self?: boolean;
size?: number;
/**
* total number of members, used to determine the number of bubbles to render when it exceeds the number of
* available profiles. slots without a profile render as placeholders.
*
* @default profiles.length
*/
count?: number;
};
export function AvatarBubbles({
animate = false,
profiles: allProfiles,
self = false,
size = 120,
count,
}: Props) {
const { currentAccount } = useSession();
const profiles =
!self && allProfiles.length > 2 ? allProfiles.filter((p) => p.did !== currentAccount?.did) : allProfiles;
const bubbleCount = Math.max(profiles.length, count ?? 0);
const [animatedIn, setAnimatedIn] = useState(false);
useEffect(() => {
if (animate) {
// oxlint-disable-next-line react/set-state-in-effect -- one-time animation start
setAnimatedIn(true);
}
}, [animate]);
const layouts = getLayouts(bubbleCount);
return (
<svg className={css.svg} width={size} height={size} viewBox={getViewBox(layouts)}>
{layouts.map((layout) => {
const profile = profiles[layout.index];
const border = layout.border ? 2 : 0;
return (
<foreignObject
key={profile?.did ?? layout.index}
x={layout.x}
y={layout.y}
width={layout.size + border * 2}
height={layout.size + border * 2}
>
<AvatarBubble
profile={profile}
scale={animate ? (animatedIn ? 1 : 0) : 1}
transitionDelay={animate ? 500 + layout.index * 100 : undefined}
size={layout.size}
includeProfileBorder={layout.border}
/>
</foreignObject>
);
})}
</svg>
);
}
function AvatarBubble({
profile: profileUnshadowed,
scale,
transitionDelay,
size,
includeProfileBorder,
}: {
profile?: AnyProfileView;
scale: number;
transitionDelay?: number;
size: number;
includeProfileBorder?: boolean;
}) {
const profile = useMaybeProfileShadow(profileUnshadowed);
const moderationOpts = useModerationOpts();
return (
<div
className={clsx(
css.bubble,
includeProfileBorder && css.bubbleBorder,
transitionDelay != null && css.bubbleAnimated,
)}
style={assignInlineVars({
[css.bubbleDelayVar]: transitionDelay != null ? `${transitionDelay}ms` : '0ms',
[css.bubbleScaleVar]: String(scale),
})}
>
{profile && moderationOpts ? (
<UserAvatar
avatar={profile.avatar}
size={size}
type="user"
hideLiveBadge
noBorder
moderation={getDisplayRestrictions(
moderateProfile(profile, moderationOpts),
DisplayContext.ProfileMedia,
)}
/>
) : (
<AvatarPlaceholder size={size} />
)}
</div>
);
}
function AvatarPlaceholder({ size }: { size: number }) {
return (
<div className={css.placeholder} style={assignInlineVars({ [css.placeholderSizeVar]: `${size}px` })}>
<PersonIcon width={size * 0.5} height={size * 0.5} />
</div>
);
}
// the layouts aren't symmetric about (60, 60), so center the cluster's bounding box in the 120-unit window.
function getViewBox(layouts: Layout[]): string {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const layout of layouts) {
// bordered slots draw a 2px ring on every side.
const outerSize = layout.size + (layout.border ? 4 : 0);
minX = Math.min(minX, layout.x);
minY = Math.min(minY, layout.y);
maxX = Math.max(maxX, layout.x + outerSize);
maxY = Math.max(maxY, layout.y + outerSize);
}
const offsetX = (minX + maxX) / 2 - 60;
const offsetY = (minY + maxY) / 2 - 60;
return `${offsetX} ${offsetY} 120 120`;
}
/** slots authored back-to-front so array order is the SVG paint order (later draws on top). */
function getLayouts(count: number): Layout[] {
if (count === 3) {
return [
{ index: 0, size: 68, x: -2, y: -2 },
{ index: 1, size: 56, x: 38, y: 62 },
{ index: 2, size: 46, x: 71, y: 18 },
];
}
if (count >= 4) {
return [
{ index: 0, size: 68, x: -2, y: -2 },
{ index: 1, size: 56, x: 60, y: 49 },
{ index: 2, size: 42, x: 14, y: 74 },
{ index: 3, size: 32, x: 72, y: 9 },
];
}
return [
{ border: true, index: 1, size: 76, x: 42, y: 42 },
{ border: true, index: 0, size: 76, x: -2, y: -2 },
];
}