import type { AnyProfileView, AppBskyActorDefs } from '@atcute/bluesky';
import type { ModerationOptions } from '@atcute/bluesky-moderation';
import { useConstant } from '#/lib/hooks/use-constant';
import { Trans } from '#/locale/Trans';
import { AvatarStack } from '#/components/AvatarStack';
import { Text } from '#/components/Text';
import * as css from '#/components/web/KnownFollowers.css';
import { Link } from '#/components/web/Link';
import { m } from '#/paraglide/messages';
/** avatar diameter per {@link KnownFollowersVariant}. */
const AVI_SIZE = { compact: 20, default: 30 } as const;
/** `'compact'` shrinks the avatars and tightens the row for dense contexts like message requests. */
type KnownFollowersVariant = 'compact' | 'default';
/**
* Whether to render {@link KnownFollowers}. Counts the returned followers rather than the `count` field, since
* `count` includes blocked users that `followers` omits.
*/
export function shouldShowKnownFollowers(knownFollowers?: AppBskyActorDefs.KnownFollowers) {
return knownFollowers && knownFollowers.followers.length > 0;
}
/**
* "Followed by X, Y and N others" — a row of overlapping avatars linking to the shared followers.
*
* @param showIfEmpty when there are no shared followers, render fallback text instead of nothing
*/
export function KnownFollowers({
moderationOpts,
profile,
showIfEmpty,
variant = 'default',
}: {
moderationOpts: ModerationOptions;
profile: AnyProfileView;
showIfEmpty?: boolean;
variant?: KnownFollowersVariant;
}) {
// stable per-instance Map; useConstant (not useRef) so reads/writes during render aren't ref accesses.
const cache = useConstant(() => new Map<string, AppBskyActorDefs.KnownFollowers>());
// `knownFollowers` isn't sorted stably, so revalidation can flash a reordered list. Cache the first
// value seen for this profile so an in-memory screen keeps a stable order.
if (profile.viewer?.knownFollowers && !cache.has(profile.did)) {
cache.set(profile.did, profile.viewer.knownFollowers);
}
const cachedKnownFollowers = cache.get(profile.did);
if (cachedKnownFollowers && shouldShowKnownFollowers(cachedKnownFollowers)) {
return (
<KnownFollowersInner
cachedKnownFollowers={cachedKnownFollowers}
moderationOpts={moderationOpts}
profile={profile}
showIfEmpty={showIfEmpty}
variant={variant}
/>
);
}
return <EmptyFallback show={showIfEmpty} />;
}
function KnownFollowersInner({
cachedKnownFollowers,
moderationOpts,
profile,
showIfEmpty,
variant,
}: {
cachedKnownFollowers: AppBskyActorDefs.KnownFollowers;
moderationOpts: ModerationOptions;
profile: AnyProfileView;
showIfEmpty?: boolean;
variant: KnownFollowersVariant;
}) {
const slice = cachedKnownFollowers.followers.slice(0, 3);
// Does not have blocks applied. Always >= slice.length
const serverCount = cachedKnownFollowers.count;
if (slice.length === 0) {
return <EmptyFallback show={showIfEmpty} />;
}
return (
<Link
className={css.link({ variant })}
label={m['common.follow.a11y.knownFollowers']()}
to={{ name: 'ProfileKnownFollowers', actor: profile.did }}
>
<AvatarStack moderationOpts={moderationOpts} profiles={slice} size={AVI_SIZE[variant]} />
<Text className={css.text} color="textContrastMedium" numberOfLines={2} size="sm">
{slice.length >= 2 ? (
serverCount > 2 ? (
<Trans
message={m['common.follow.followedByMany']}
inputs={{
count: serverCount - 2,
name: slice[0]!.handle,
name2: slice[1]!.handle,
}}
markup={{
t0: ({ children }) => (
<Text color="textContrastMedium" size="sm">
{children}
</Text>
),
t1: ({ children }) => (
<Text color="textContrastMedium" size="sm">
{children}
</Text>
),
}}
/>
) : (
<Trans
message={m['common.follow.followedByTwo']}
inputs={{
name: slice[0]!.handle,
name2: slice[1]!.handle,
}}
markup={{
t0: ({ children }) => (
<Text color="textContrastMedium" size="sm">
{children}
</Text>
),
t1: ({ children }) => (
<Text color="textContrastMedium" size="sm">
{children}
</Text>
),
}}
/>
)
) : serverCount > 1 ? (
<Trans
message={m['common.follow.followedByOthers']}
inputs={{ count: serverCount - 1, name: slice[0]!.handle }}
markup={{
t0: ({ children }) => (
<Text color="textContrastMedium" size="sm">
{children}
</Text>
),
}}
/>
) : (
<Trans
message={m['common.follow.followedBy']}
inputs={{ name: slice[0]!.handle }}
markup={{
t0: ({ children }) => (
<Text color="textContrastMedium" size="sm">
{children}
</Text>
),
}}
/>
)}
</Text>
</Link>
);
}
function EmptyFallback({ show }: { show?: boolean }) {
if (!show) {
return null;
}
return (
<Text color="textContrastMedium" size="sm">
{m['common.follow.knownFollowers.empty']()}
</Text>
);
}