import type { ReactNode } from 'react';
import type { AnyProfileView } from '@atcute/bluesky';
import {
DisplayContext,
getDisplayRestrictions,
moderateProfile,
type ModerationOptions,
} from '@atcute/bluesky-moderation';
import { weightedIndex } from '@mary/array-fns';
import { clsx } from 'clsx';
import { sanitizeDisplayName } from '#/lib/display-names';
import { isAbortError } from '#/lib/errors';
import { getModerationCauseKey } from '#/lib/moderation/causes';
import { profileTarget } from '#/lib/routes/targets';
import { useProfileShadow } from '#/state/cache/profile-shadow';
import { useProfileFollowMutationQueue } from '#/state/queries/profile';
import { useSession } from '#/state/session';
import { useActorStatus } from '#/features/liveNow/use-actor-status';
import { BlockLink } from '#/components/BlockLink';
import { PreviewableUserAvatar } from '#/components/PreviewableUserAvatar';
import { ProfileBadges } from '#/components/ProfileBadges';
import { RichText, type RichTextProps } from '#/components/RichText';
import { Text } from '#/components/Text';
import * as Toast from '#/components/Toast';
import { UserAvatar } from '#/components/UserAvatar';
import { Button, ButtonIcon, ButtonText } from '#/components/web/Button';
import * as Pills from '#/components/web/Pills';
import * as css from '#/components/web/ProfileCard.css';
import * as Skeleton from '#/components/web/Skeleton';
import CheckIcon from '#/icons/central/Checkmark2_round_outlined_radius1_stroke2.svg';
import PlusIcon from '#/icons/central/PlusLarge_round_outlined_radius1_stroke2.svg';
import { m } from '#/paraglide/messages';
/**
* default profile card presentation displaying an avatar, name/handle, follow button, moderation labels, and
* bio.
*
* @param descriptionLines max bio lines before truncation.
* @param followButtonProps overrides forwarded to the trailing FollowButton.
* @param showLabels whether to render the moderation/follows-you Labels row.
* @param topBorder whether the row carries a top divider.
*/
export function Default({
descriptionLines = 3,
followButtonProps,
moderationOpts,
onPress,
profile,
showLabels = true,
topBorder,
}: {
descriptionLines?: number;
followButtonProps?: Partial<Omit<FollowButtonProps, 'profile'>>;
moderationOpts: ModerationOptions;
onPress?: () => void;
profile: AnyProfileView;
showLabels?: boolean;
topBorder?: boolean;
}) {
return (
<Link className={css.defaultRow({ topBorder })} onPress={onPress} profile={profile}>
<Outer>
<Header>
<Avatar moderationOpts={moderationOpts} profile={profile} />
<NameAndHandle moderationOpts={moderationOpts} profile={profile} />
<FollowButton profile={profile} {...followButtonProps} />
</Header>
{showLabels && <Labels moderationOpts={moderationOpts} profile={profile} />}
<Description numberOfLines={descriptionLines} profile={profile} />
</Outer>
</Link>
);
}
/** Vertical card container: stacks the header, labels, and description. */
export function Outer({ children, className }: { children: ReactNode; className?: string }) {
return <div className={clsx(css.outer, className)}>{children}</div>;
}
/** Horizontal row laying out an avatar, name/handle, and trailing action. */
export function Header({ children, className }: { children: ReactNode; className?: string }) {
return <div className={clsx(css.header, className)}>{children}</div>;
}
/** Block link to a profile wrapping a card row; `className` supplies the row layout. */
export function Link({
children,
className,
onPress,
profile,
}: {
children: ReactNode;
className?: string;
onPress?: () => void;
profile: AnyProfileView;
}) {
return (
// BlockLink (role=link <div>), not an <a>, so the row can hold interactive children (a previewable
// avatar, a follow button) without nesting them in an anchor
<BlockLink
className={clsx(css.link, className)}
label={m['common.profile.a11y.viewNamed']({
name: profile.handle,
})}
onBeforePress={onPress}
to={profileTarget(profile.did)}
>
<div>{children}</div>
</BlockLink>
);
}
/** A profile avatar; previewable on hover unless `disabledPreview` is set. */
export function Avatar({
disabledPreview,
hideLiveBadge,
liveOverride,
moderationOpts,
onPress,
profile,
size = 40,
}: {
disabledPreview?: boolean;
hideLiveBadge?: boolean;
liveOverride?: boolean;
moderationOpts: ModerationOptions;
onPress?: () => void;
profile: AnyProfileView;
size?: number;
}) {
const moderation = moderateProfile(profile, moderationOpts);
const { isActive: live } = useActorStatus(profile);
return disabledPreview ? (
<UserAvatar
avatar={profile.avatar}
hideLiveBadge={hideLiveBadge}
live={liveOverride ?? live}
moderation={getDisplayRestrictions(moderation, DisplayContext.ProfileMedia)}
size={size}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
) : (
<PreviewableUserAvatar
hideLiveBadge={hideLiveBadge}
live={liveOverride ?? live}
moderation={getDisplayRestrictions(moderation, DisplayContext.ProfileMedia)}
onBeforePress={onPress}
profile={profile}
size={size}
/>
);
}
/** Stacked name + handle. */
export function NameAndHandle({
moderationOpts,
profile,
}: {
moderationOpts: ModerationOptions;
profile: AnyProfileView;
}) {
return (
<span className={css.nameAndHandle}>
<Handle profile={profile} />
<Name moderationOpts={moderationOpts} profile={profile} />
</span>
);
}
export function Name({
color = 'textContrastMedium',
moderationOpts,
profile,
size = 'md_sub',
weight = 'normal',
}: {
color?: 'text' | 'textContrastMedium';
moderationOpts: ModerationOptions;
profile: AnyProfileView;
size?: 'md_sub' | 'lg';
weight?: 'normal' | 'semiBold';
}) {
if (!profile.displayName) {
return null;
}
const moderation = moderateProfile(profile, moderationOpts);
const name = sanitizeDisplayName(
profile.displayName,
getDisplayRestrictions(moderation, DisplayContext.ProfileBio),
);
if (!name) {
return null;
}
return (
<Text numberOfLines={1} color={color} size={size} weight={weight}>
{name}
</Text>
);
}
export function Handle({ className, profile }: { className?: string; profile: AnyProfileView }) {
const handle = profile.handle;
return (
<span className={clsx(css.handleRow, className)}>
<Text className={css.handleText} color="textContrastHigh" weight="semiBold" numberOfLines={1}>
{handle}
</Text>
<span className={css.badges}>
<ProfileBadges profile={profile} size="md" />
</span>
</span>
);
}
/**
* Skeleton circle standing in for an avatar while a profile loads.
*
* @param color contrast level of the placeholder; raise it on tinted surfaces.
*/
export function AvatarPlaceholder({ color, size = 40 }: { color?: Skeleton.Color; size?: number }) {
return <Skeleton.Circle color={color} size={size} />;
}
/**
* Skeleton bars standing in for the name column while a profile loads
*
* @param color contrast level of the placeholders; raise it on tinted surfaces.
*/
export function NameAndHandlePlaceholder({ color }: { color?: Skeleton.Color }) {
return (
<Skeleton.Col grow>
<Skeleton.Text color={color} size="md" width="20%" />
<Skeleton.Text color={color} size="md_sub" width="25%" />
</Skeleton.Col>
);
}
/** Profile bio as inline richtext; renders nothing when empty or when the viewer is blocked. */
export function Description({
align,
color,
numberOfLines = 3,
profile: profileUnshadowed,
size,
}: {
numberOfLines?: number;
profile: AnyProfileView;
} & Pick<RichTextProps, 'align' | 'color' | 'size'>) {
const profile = useProfileShadow(profileUnshadowed);
if (!('description' in profile) || !profile.description) {
return null;
}
if (
profile.viewer &&
(profile.viewer.blockedBy || profile.viewer.blocking || profile.viewer.blockingByList)
) {
return null;
}
return (
<RichText
align={align}
color={color}
disableLinks
numberOfLines={numberOfLines}
size={size}
value={profile.description}
/>
);
}
// weighted bio-line counts: most profiles carry a short bio, clustering around 1–2 lines with a tail toward
// 3 and a few empty. index = line count (0–3).
const DESCRIPTION_LINE_WEIGHTS = [3, 8, 6, 3];
function LoadingRow({ descriptionLines, topBorder }: { descriptionLines: number; topBorder: boolean }) {
return (
<div className={css.loadingRow({ topBorder })}>
<Skeleton.Row align="center" gap="md">
<AvatarPlaceholder />
<NameAndHandlePlaceholder />
</Skeleton.Row>
{descriptionLines > 0 && <Skeleton.Lines count={descriptionLines} lastWidth={60} size="md" />}
</div>
);
}
// fallback row count when the caller doesn't know how many profiles to expect, and a cap so a large count
// doesn't render dozens of placeholder rows.
const DEFAULT_LOADING_ROW_COUNT = 3;
const MAX_LOADING_ROW_COUNT = 10;
/**
* stack of profile-card placeholders for the loading state.
*
* @param count number of placeholder rows. capped to prevent excessive rendering.
* @param topBorder whether the first row carries a top divider. defaults to `false`.
*/
export function LoadingPlaceholder({
count,
topBorder = false,
}: {
count?: number;
topBorder?: boolean;
}): ReactNode {
const rowCount = Math.min(count ?? DEFAULT_LOADING_ROW_COUNT, MAX_LOADING_ROW_COUNT);
const rows = Array.from({ length: rowCount }, () => ({
descriptionLines: weightedIndex(DESCRIPTION_LINE_WEIGHTS),
}));
return (
<>
{rows.map((row, i) => (
// oxlint-disable-next-line react/no-array-index-key -- static skeleton
<LoadingRow descriptionLines={row.descriptionLines} key={i} topBorder={i === 0 ? topBorder : true} />
))}
</>
);
}
/** Moderation alert/inform pills plus a "follows you" marker; renders nothing when there's none to show. */
export function Labels({
moderationOpts,
profile,
}: {
moderationOpts: ModerationOptions;
profile: AnyProfileView;
}) {
const moderation = moderateProfile(profile, moderationOpts);
const modui = getDisplayRestrictions(moderation, DisplayContext.ProfileList);
const followedBy = profile.viewer?.followedBy;
const mutedOnlyReposts = profile.viewer?.mutedOnlyReposts;
if (!followedBy && !mutedOnlyReposts && modui.alerts.length === 0 && modui.informs.length === 0) {
return null;
}
return (
<Pills.Row className={css.labels}>
{followedBy && <Pills.FollowsYou />}
{modui.alerts.map((alert) => (
<Pills.Label cause={alert} key={getModerationCauseKey(alert)} />
))}
{modui.informs.map((inform) => (
<Pills.Label cause={inform} key={getModerationCauseKey(inform)} />
))}
{mutedOnlyReposts && <Pills.MutedOnlyReposts />}
</Pills.Row>
);
}
export type FollowButtonVariant = 'default' | 'text-only' | 'suggested' | 'subtle';
export type FollowButtonProps = {
profile: AnyProfileView;
variant?: FollowButtonVariant;
};
/** Follow/unfollow toggle. Renders nothing for the signed-in user's own profile or when signed out. */
export function FollowButton(props: FollowButtonProps) {
const { currentAccount, hasSession } = useSession();
const isMe = props.profile.did === currentAccount?.did;
return hasSession && !isMe ? <FollowButtonInner {...props} /> : null;
}
function FollowButtonInner({ profile: profileUnshadowed, variant = 'default' }: FollowButtonProps) {
const profile = useProfileShadow(profileUnshadowed);
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile);
const isRound = variant === 'suggested';
const followingWithIcon = variant !== 'text-only';
const followWithIcon = variant !== 'text-only' && variant !== 'subtle';
const onPressFollow = async () => {
try {
await queueFollow();
Toast.show(m['common.follow.a11y.following']({ name: profile.handle }));
} catch (err) {
if (!isAbortError(err)) {
Toast.show(m['common.error.generic'](), { type: 'error' });
}
}
};
const onPressUnfollow = async () => {
try {
await queueUnfollow();
Toast.show(m['common.follow.noLongerFollowing']({ name: profile.handle }));
} catch (err) {
if (!isAbortError(err)) {
Toast.show(m['common.error.generic'](), { type: 'error' });
}
}
};
const unfollowLabel = m['common.follow.action.following']();
const followLabel = profile.viewer?.followedBy
? m['common.follow.action.followBack']()
: m['common.follow.action.follow']();
if (!profile.viewer) {
return null;
}
if (profile.viewer.blockedBy || profile.viewer.blocking || profile.viewer.blockingByList) {
return null;
}
return profile.viewer.following ? (
<Button
label={unfollowLabel}
size="small"
variant={variant === 'subtle' ? 'ghost' : 'solid'}
color="secondary"
shape={isRound ? 'round' : 'default'}
onClick={() => void onPressUnfollow()}
>
{followingWithIcon && <ButtonIcon icon={CheckIcon} />}
{!isRound && <ButtonText>{unfollowLabel}</ButtonText>}
</Button>
) : (
<Button
label={followLabel}
size="small"
variant="solid"
color={
variant === 'suggested' ? 'secondary_inverted' : variant === 'subtle' ? 'primary_subtle' : 'primary'
}
shape={isRound ? 'round' : 'default'}
onClick={() => void onPressFollow()}
>
{followWithIcon && <ButtonIcon icon={PlusIcon} />}
{!isRound && <ButtonText>{followLabel}</ButtonText>}
</Button>
);
}