import { type ReactNode, useEffect } from 'react';
import type { AnyProfileView, AppBskyFeedDefs } 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 { feedTarget } from '#/lib/routes/targets';
import { precacheFeedFromGeneratorView, useFeedSourceInfoQuery } from '#/state/queries/feed';
import { useToggleSavedFeed } from '#/state/queries/preferences';
import { useSession } from '#/state/session';
import { BlockLink } from '#/components/BlockLink';
import { MissingFeed } from '#/components/MissingFeed';
import { useNavigationDisabled } from '#/components/NavigationDisabled';
import * as Prompt from '#/components/Prompt';
import { RichText } from '#/components/RichText';
import { Spinner } from '#/components/Spinner';
import { Text } from '#/components/Text';
import { UserAvatar } from '#/components/UserAvatar';
import { Button, ButtonIcon, ButtonText } from '#/components/web/Button';
import * as Skeleton from '#/components/web/Skeleton';
import PinIcon from '#/icons/central/Thumbtack_round_outlined_radius1_stroke2.svg';
import { m } from '#/paraglide/messages';
import type { RouteTarget } from '#/router';
import { borderRadius } from '#/styles/tokens.css';
import * as css from './FeedCard.css';
type Props = {
onPress?: () => void;
view: AppBskyFeedDefs.GeneratorView;
};
export function Default({
onPress,
topBorder,
view,
}: Props & {
topBorder?: boolean;
}) {
return (
<Link className={css.defaultRow({ topBorder })} onPress={onPress} view={view}>
<Outer>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline creator={view.creator} title={view.displayName} />
<SaveButton pin view={view} />
</Header>
<Description description={view.description} />
<Likes count={view.likeCount || 0} />
</Outer>
</Link>
);
}
/**
* resolves a feed generator by its AT-URI and renders a compact, non-link card for embedding inside another
* surface. shows a loading placeholder while resolving and a fallback if the feed cannot be loaded.
*
* @param uri feed generator AT-URI
*/
export function ByUri({ uri }: { uri: string }) {
const { data: feed, error } = useFeedSourceInfoQuery({ uri });
if (!feed || feed.type !== 'feed' || !feed.view) {
if (error) {
return <MissingFeed uri={uri} error={error} hideTopBorder />;
}
return <EmbedPlaceholder />;
}
const { view } = feed;
return (
<Outer className={css.embedCard}>
<Header>
<Avatar src={view.avatar} />
<TitleAndByline creator={view.creator} title={view.displayName} />
</Header>
<Likes count={view.likeCount || 0} />
</Outer>
);
}
function EmbedPlaceholder() {
return (
<Outer className={css.embedCard}>
<Header>
<AvatarPlaceholder />
<TitleAndBylinePlaceholder creator />
</Header>
<Skeleton.Text size="sm" width="35%" />
</Outer>
);
}
export function Link({
children,
className,
onPress,
view,
}: Props & {
children: ReactNode;
className?: string;
}) {
const queryClient = useQueryClient();
const navigationDisabled = useNavigationDisabled();
const target = createProfileFeedTarget({ feed: view });
useEffect(() => {
precacheFeedFromGeneratorView(queryClient, view);
}, [queryClient, view]);
return (
<BlockLink
className={clsx(css.link({ interactive: !navigationDisabled }), className)}
label={view.displayName}
onBeforePress={onPress}
to={target}
>
<div>{children}</div>
</BlockLink>
);
}
export function Outer({ children, className }: { children: ReactNode; className?: string }) {
return <div className={clsx(css.outer, className)}>{children}</div>;
}
export function Header({ children }: { children: ReactNode }) {
return <div className={css.header}>{children}</div>;
}
export type AvatarProps = { size?: number; src: string | undefined };
export function Avatar({ size = 40, src }: AvatarProps) {
return <UserAvatar avatar={src} size={size} type="algo" />;
}
export function AvatarPlaceholder({ size = 40 }: Omit<AvatarProps, 'src'>) {
return <Skeleton.Square radius={borderRadius.sm} size={size} />;
}
export function TitleAndByline({ creator, title }: { creator?: AnyProfileView; title: string }) {
return (
<div className={css.titleColumn}>
<Text numberOfLines={1} weight="medium">
{title}
</Text>
{creator && (
<Text color="textContrastMedium" numberOfLines={1} size="md_sub">
{m['common.feeds.feedBy']({ handle: creator.handle })}
</Text>
)}
</div>
);
}
export function TitleAndBylinePlaceholder({ creator }: { creator?: boolean }) {
return (
<Skeleton.Col grow>
<Skeleton.Text size="md" width="60%" />
{creator && <Skeleton.Text color="contrast_25" size="md" width="40%" />}
</Skeleton.Col>
);
}
/** Placeholder for the "Pin feed" / "Unpin feed" pill in the header. */
export function SaveButtonPlaceholder() {
return <div className={css.saveButtonPlaceholder} />;
}
export function Description({ description }: { description?: string }) {
if (!description) {
return null;
}
return <RichText disableLinks value={description} />;
}
export function Likes({ count }: { count: number }) {
return (
<Text color="textContrastMedium" size="sm" weight="semiBold">
{m['common.like.likedByCount']({ count: count || 0 })}
</Text>
);
}
export function SaveButton({ pin, view }: { pin?: boolean; view: AppBskyFeedDefs.GeneratorView }) {
const { hasSession } = useSession();
if (!hasSession) {
return null;
}
return <SaveButtonInner pin={pin} view={view} />;
}
function SaveButtonInner({ pin, view }: { pin?: boolean; view: AppBskyFeedDefs.GeneratorView }) {
const removePromptHandle = Prompt.usePromptHandle();
const { isPending, isSaved, toggleSave } = useToggleSavedFeed({ type: 'feed', uri: view.uri, pin });
return (
<>
<Button
color={isSaved ? 'secondary' : 'primary'}
disabled={isPending}
label={m['common.feeds.action.add']()}
onClick={isSaved ? () => removePromptHandle.open(null) : () => void toggleSave()}
size="small"
variant="solid"
>
{isSaved ? (
<>
{isPending && <Spinner color="white" label={null} size="sm" />}
<ButtonText>{m['common.feeds.action.unpin']()}</ButtonText>
</>
) : (
<>
{isPending ? (
<Spinner color="white" label={null} size="sm" />
) : (
<ButtonIcon icon={PinIcon} size="md" />
)}
<ButtonText>{m['common.feeds.action.pin']()}</ButtonText>
</>
)}
</Button>
<Prompt.Basic
confirmButtonColor="negative"
confirmButtonCta={m['common.action.remove']()}
description={m['common.feeds.remove.message']()}
handle={removePromptHandle}
onConfirm={() => void toggleSave()}
title={m['common.feeds.remove.title']()}
/>
</>
);
}
function createProfileFeedTarget({ feed }: { feed: AppBskyFeedDefs.GeneratorView }): RouteTarget {
const urip = parseCanonicalResourceUri(feed.uri);
return feedTarget(feed.creator.did, urip.rkey);
}
// weighted description-line counts: most feed descriptions are short (1 line) or empty, with a long
// tail toward 2–3 lines. index = line count (0–3).
const DESCRIPTION_LINE_WEIGHTS = [20, 10, 4, 1];
function LoadingRow({ descriptionLines, topBorder }: { descriptionLines: number; topBorder: boolean }) {
return (
<Skeleton.Col className={css.loadingRow({ topBorder })} gap="md">
<Skeleton.Row align="center" gap="md">
<AvatarPlaceholder />
<TitleAndBylinePlaceholder creator />
<SaveButtonPlaceholder />
</Skeleton.Row>
{descriptionLines > 0 && <Skeleton.Lines count={descriptionLines} lastWidth={70} size="md" />}
<Skeleton.Text size="sm" width="35%" />
</Skeleton.Col>
);
}
// fallback row count when the caller doesn't know how many feeds to expect, and a cap so a profile with
// many feeds doesn't render dozens of placeholder rows.
const DEFAULT_LOADING_ROW_COUNT = 3;
const MAX_LOADING_ROW_COUNT = 10;
/**
* stack of feed-card placeholders for the loading state.
*
* @param count number of placeholder rows.
* @param topBorder whether the first row carries a top divider.
*/
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 }, () => ({
// ~5% of feeds carry no description; the rest cluster around 1 line with a long tail.
descriptionLines: weightedIndex(DESCRIPTION_LINE_WEIGHTS),
}));
return (
<>
{rows.map((row, i) => (
// oxlint-disable-next-line react/no-array-index-key -- static skeleton
<LoadingRow key={i} descriptionLines={row.descriptionLines} topBorder={i === 0 ? topBorder : true} />
))}
</>
);
}