import type { ReactNode } from 'react';
import type { AnyStarterPackView } from '@atcute/bluesky';
import { parseCanonicalResourceUri } from '@atcute/lexicons/syntax';
import { weightedIndex } from '@mary/array-fns';
import { useQueryClient } from '@tanstack/react-query';
import { clsx } from 'clsx';
import { getStarterPackRecord } from '#/lib/api/record-casts';
import { starterPackTarget } from '#/lib/routes/targets';
import { getStarterPackOgCard } from '#/lib/starter-pack';
import { precacheResolvedUri } from '#/state/queries/resolve-uri';
import { precacheStarterPack } from '#/state/queries/starter-packs';
import { useSession } from '#/state/session';
import { EmbedThumb } from '#/components/EmbedThumb';
import { useNavigationDisabled } from '#/components/NavigationDisabled';
import { Text } from '#/components/Text';
import { Link as WebLink } from '#/components/web/Link';
import * as Skeleton from '#/components/web/Skeleton';
import StarterPackIcon from '#/icons/original/StarterPackSky.svg';
import { m } from '#/paraglide/messages';
import { borderRadius } from '#/styles/tokens.css';
import * as css from './StarterPackCard.css';
type DefaultProps = {
starterPack?: AnyStarterPackView;
topBorder?: boolean;
};
/** A list-row starter pack card */
export function Default({ starterPack, topBorder }: DefaultProps) {
if (!starterPack) {
return null;
}
return (
<Link className={css.defaultRow({ topBorder })} starterPack={starterPack}>
<Card starterPack={starterPack} />
</Link>
);
}
/** A compact starter pack card for notifications. */
export function Notification({ starterPack }: { starterPack?: AnyStarterPackView }) {
if (!starterPack) {
return null;
}
return (
<Outer>
<Header>
<TitleAndByline starterPack={starterPack} />
</Header>
<JoinedCount starterPack={starterPack} />
</Outer>
);
}
/** The full card body */
export function Card({ starterPack }: { starterPack: AnyStarterPackView }) {
return (
<Outer>
<Header>
<Icon />
<TitleAndByline starterPack={starterPack} />
</Header>
<Description starterPack={starterPack} />
<JoinedCount starterPack={starterPack} />
</Outer>
);
}
function Outer({ children }: { children: ReactNode }) {
return <div className={css.outer}>{children}</div>;
}
function Header({ children }: { children: ReactNode }) {
return <div className={css.header}>{children}</div>;
}
function Icon() {
return <StarterPackIcon className={css.starterPackIcon} />;
}
function TitleAndByline({ starterPack }: { starterPack: AnyStarterPackView }) {
const { currentAccount } = useSession();
const { creator } = starterPack;
const record = getStarterPackRecord(starterPack);
return (
<div className={css.titleColumn}>
<Text size="md" weight="semiBold" numberOfLines={2}>
{record.name}
</Text>
<Text size="md_sub" color="textContrastMedium" numberOfLines={1}>
{creator?.did === currentAccount?.did
? m['common.starterPack.byYou']()
: m['components.starterPack.card.byline']({ handle: creator.handle })}
</Text>
</div>
);
}
function Description({ starterPack }: { starterPack: AnyStarterPackView }) {
const record = getStarterPackRecord(starterPack);
if (!record.description) {
return null;
}
return (
<Text size="md_sub" numberOfLines={3}>
{record.description}
</Text>
);
}
function JoinedCount({ starterPack }: { starterPack: AnyStarterPackView }) {
const { joinedAllTimeCount } = starterPack;
if (!joinedAllTimeCount || joinedAllTimeCount < 50) {
return null;
}
return (
<Text size="md_sub" weight="semiBold" color="textContrastMedium">
{m['components.starterPack.card.joinedCount']({ count: joinedAllTimeCount })}
</Text>
);
}
/**
* Builds a navigable link to the starter pack screen plus a precache helper for the creator and starter pack
* queries.
*/
export function useStarterPackLink({ view }: { view: AnyStarterPackView }) {
const qc = useQueryClient();
const rkey = parseCanonicalResourceUri(view.uri).rkey;
const did = view.creator.did;
const record = getStarterPackRecord(view);
const precache = () => {
precacheResolvedUri(qc, view.creator.handle, view.creator.did);
precacheStarterPack(qc, view);
};
return {
to: starterPackTarget(did, rkey),
label: m['components.starterPack.card.navigate']({ name: record.name }),
precache,
};
}
/** A clickable wrapper that navigates to the starter pack screen and precaches its data. */
export function Link({
starterPack,
children,
className,
onPress,
}: {
starterPack: AnyStarterPackView;
children: ReactNode;
className?: string;
onPress?: () => void;
}) {
const queryClient = useQueryClient();
const navigationDisabled = useNavigationDisabled();
const record = getStarterPackRecord(starterPack);
const rkey = parseCanonicalResourceUri(starterPack.uri).rkey;
const did = starterPack.creator.did;
return (
<WebLink
to={starterPackTarget(did, rkey)}
label={m['components.starterPack.card.navigate']({ name: record.name })}
className={clsx(css.link({ interactive: !navigationDisabled }), className)}
onPress={() => {
precacheResolvedUri(queryClient, starterPack.creator.handle, starterPack.creator.did);
precacheStarterPack(queryClient, starterPack);
onPress?.();
}}
>
{children}
</WebLink>
);
}
/** A starter pack embedded in a post: OG card image on top and the card body below. */
export function Embed({ starterPack }: { starterPack: AnyStarterPackView }) {
const navigationDisabled = useNavigationDisabled();
const imageUri = getStarterPackOgCard(starterPack);
return (
<Link starterPack={starterPack} className={css.embedCard({ interactive: !navigationDisabled })}>
<EmbedThumb src={imageUri} />
<div className={css.embedBody}>
<Card starterPack={starterPack} />
</div>
</Link>
);
}
/** Placeholder for the header's 40×40 starter-pack icon block. */
function IconPlaceholder() {
return <Skeleton.Square radius={borderRadius.sm} size={40} />;
}
/** Placeholder for the title + byline stack that sits beside the icon. */
function TitleAndBylinePlaceholder() {
return (
<Skeleton.Col grow>
<Skeleton.Text size="md" width="60%" />
<Skeleton.Text color="contrast_25" size="md_sub" width="40%" />
</Skeleton.Col>
);
}
// weighted description-line counts: most starter pack descriptions are short (1 line) or empty, with a long
// tail toward 2–3. index = line count (0–3).
const DESCRIPTION_LINE_WEIGHTS = [12, 10, 4, 1];
function LoadingRow({
descriptionLines,
joinedCount,
topBorder,
}: {
descriptionLines: number;
joinedCount: boolean;
topBorder: boolean;
}) {
return (
<Skeleton.Col className={css.loadingRow({ topBorder })} gap="sm">
<Skeleton.Row align="start" gap="sm">
<IconPlaceholder />
<TitleAndBylinePlaceholder />
</Skeleton.Row>
{descriptionLines > 0 && <Skeleton.Lines count={descriptionLines} lastWidth={70} size="md_sub" />}
{joinedCount && <Skeleton.Text size="md_sub" width="35%" />}
</Skeleton.Col>
);
}
// fallback row count when the caller doesn't know how many starter packs to expect, and a cap so a profile
// with many packs doesn't render dozens of placeholder rows.
const DEFAULT_LOADING_ROW_COUNT = 3;
const MAX_LOADING_ROW_COUNT = 10;
// ~10% of packs have 50+ all-time joins and show a "joined" line; the rest omit it.
const JOINED_COUNT_WEIGHT = [9, 1];
/**
* loading placeholders for starter pack cards.
*
* @param count number of placeholder rows.
*/
export function LoadingPlaceholder({ count }: { count?: number }): ReactNode {
const rowCount = Math.min(count ?? DEFAULT_LOADING_ROW_COUNT, MAX_LOADING_ROW_COUNT);
const rows = Array.from({ length: rowCount }, () => ({
// starter pack descriptions are often empty or short; weight toward 0–1 lines with a tail toward 2–3.
descriptionLines: weightedIndex(DESCRIPTION_LINE_WEIGHTS),
joinedCount: weightedIndex(JOINED_COUNT_WEIGHT) === 1,
}));
return (
<>
{rows.map((row, i) => (
<LoadingRow
// oxlint-disable-next-line react/no-array-index-key -- static skeleton
key={i}
descriptionLines={row.descriptionLines}
joinedCount={row.joinedCount}
topBorder={i !== 0}
/>
))}
</>
);
}