refactor: migrate profile feedgens to the new list state components
drops the sentinel-row machinery in favour of the whole-surface components, and fixes two defects it was hiding: `isEmpty` is true whenever there is no data, failure included, so an initial error pushed `ERROR_ITEM` and then `EMPTY` — the screen told the user the request had failed and that they had no feeds, stacked. it now renders a single `ListError`. `LOAD_MORE_ERROR_ITEM` was unreachable: its branch required `!isEmpty`, which implies `data?.pages` is truthy, so the preceding branch always won. `LoadMoreRetryBtn` never rendered here. also drops two inert props on the way past. `hasNextPage` is only read as `!hasNextPage && showEndMessage`, and `showEndMessage` was never passed; `height={180}` restated the css default. the footer additionally rendered during the initial load, since `isEmpty` is gated on `!isPending`, putting a bordered 180px box under the skeleton. Claude-Session: https://claude.ai/code/session_01DZRW2fBuJdbQENW4Rq9fy8
123345687891213101516171112131415161718192320212627282930313233343536373839404142222345242526275051525328295630585960616263646566676869707172313233747534357778793637383940414282838485868788899091929394959697989910010143441021031041051061071081091101111121131141151161171181191204512212312412512612712846474849505152535455565758596061626364656667132686913413570717213713813914014114214314414573747576777879801478182838485152153154155import type { ReactNode } from 'react';
import type { AppBskyFeedDefs } from '@atcute/bluesky';import type { Did } from '@atcute/lexicons';
import { cleanError } from '#/lib/errors';
import { usePreferencesQuery } from '#/state/queries/preferences';import { useProfileFeedgensQuery } from '#/state/queries/profile-feedgens';import { useSession } from '#/state/session';
import { EmptyState } from '#/components/EmptyState';import { ErrorMessage } from '#/components/ErrorMessage';import * as FeedCard from '#/components/FeedCard';import { List, type ListRenderItemInfo } from '#/components/List/List';import { ListFooter } from '#/components/Lists';import { LoadMoreRetryBtn } from '#/components/LoadMoreRetryBtn';import { List } from '#/components/List/List';import { ListEmpty } from '#/components/List/ListEmpty';import { ListError } from '#/components/List/ListError';import * as ListTail from '#/components/List/ListTail';
import HashtagWideIcon from '#/icons/central/Hashtag_round_outlined_radius1_stroke1.svg';import { m } from '#/paraglide/messages';import { useRouter } from '#/router';
// only governs rows that have never been on screen; the browser reuses the real size once rendered.const FEEDGEN_ITEM_HEIGHT_ESTIMATE = 120;
const LOADING = { _reactKey: '__loading__' } as const;const EMPTY = { _reactKey: '__empty__' } as const;const ERROR_ITEM = { _reactKey: '__error__' } as const;const LOAD_MORE_ERROR_ITEM = { _reactKey: '__load_more_error__' } as const;
type FeedgenItem = | AppBskyFeedDefs.GeneratorView | typeof EMPTY | typeof ERROR_ITEM | typeof LOADING | typeof LOAD_MORE_ERROR_ITEM;type FeedgenSentinel = Exclude<FeedgenItem, AppBskyFeedDefs.GeneratorView>;
const isFeedgenSentinel = (item: FeedgenItem): item is FeedgenSentinel => { return '_reactKey' in item;};
interface ProfileFeedgensProps { did: Did; /** Known feed-generator count, used to size the loading skeleton; falls back to a small default. */ feedCount?: number;}
export function ProfileFeedgens({ did, feedCount }: ProfileFeedgensProps): ReactNode { const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage, isError, error, refetch } = useProfileFeedgensQuery(did); const isEmpty = !isPending && !data?.pages.some((page) => page.feeds.length); const { data: preferences } = usePreferencesQuery(); const router = useRouter(); const { currentAccount } = useSession(); const isSelf = currentAccount?.did === did;
let items: FeedgenItem[] = []; if (isError && isEmpty) { items = items.concat([ERROR_ITEM]); } if (isPending) { items = items.concat([LOADING]); } else if (isEmpty) { items = items.concat([EMPTY]); } else if (data?.pages) { for (const page of data.pages) { items = items.concat(page.feeds); } } else if (isError && !isEmpty) { items = items.concat([LOAD_MORE_ERROR_ITEM]); } const { data, error, fetchNextPage, isError, isFetchingNextPage, isPending, refetch } = useProfileFeedgensQuery(did);
// events // = const isSelf = currentAccount?.did === did;
const onEndReached = () => { if (isFetchingNextPage || !hasNextPage || isError) { return; const feeds = data?.pages.flatMap((page) => page.feeds) ?? [];
if (feeds.length < 1) { if (isError) { return <ListError hideBackButton message={cleanError(error)} onRetry={() => void refetch()} />; }
void fetchNextPage(); };
const onPressRetryLoadMore = () => { void fetchNextPage(); };
// rendering // =
const renderItem = ({ index, item }: ListRenderItemInfo<FeedgenItem>) => { if (isFeedgenSentinel(item)) { if (item === ERROR_ITEM) { return <ErrorMessage message={cleanError(error)} onPressTryAgain={() => void refetch()} />; } if (item === LOAD_MORE_ERROR_ITEM) { return <LoadMoreRetryBtn label={m['common.list.fetchError']()} onPress={onPressRetryLoadMore} />; } if (item === LOADING) { return <FeedCard.LoadingPlaceholder count={feedCount} />; if (isPending) { return <FeedCard.LoadingPlaceholder count={feedCount} />; } return ( <EmptyState icon={HashtagWideIcon} message={isSelf ? m['view.feeds.saved.empty.message']() : m['view.feeds.saved.empty.title']()} messageColor="textContrastMedium" button={ isSelf ? { label: m['view.feeds.discover.browse'](), text: m['view.feeds.discover.browse'](), onPress: () => router.navigate({ to: { name: 'Feeds' } }), size: 'small', color: 'secondary', } : undefined } /> ); } if (preferences) { // the first feed card sits flush under the sticky tab bar; drop its top separator so the two // don't draw a doubled line. return <FeedCard.Default view={item} topBorder={index !== 0} />; } return null; };
return ( <ListEmpty icon={HashtagWideIcon} message={isSelf ? m['view.feeds.saved.empty.message']() : m['view.feeds.saved.empty.title']()} button={ isSelf ? { label: m['view.feeds.discover.browse'](), text: m['view.feeds.discover.browse'](), onPress: () => router.navigate({ to: { name: 'Feeds' } }), size: 'small', color: 'secondary', } : undefined } /> ); }
return ( <List data={items} data={feeds} estimateHeight={FEEDGEN_ITEM_HEIGHT_ESTIMATE} keyExtractor={keyExtractor} renderItem={renderItem} keyExtractor={(item) => item.uri} renderItem={({ index, item }) => <FeedCard.Default view={item} topBorder={index !== 0} />} ListFooterComponent={ isEmpty ? null : ( <ListFooter hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onRetry={fetchNextPage} error={cleanError(error)} height={180} /> ) <ListTail.Frame> {isFetchingNextPage ? ( <ListTail.Pending /> ) : isError ? ( <ListTail.Error message={cleanError(error)} onRetry={() => void fetchNextPage()} /> ) : null} </ListTail.Frame> } onEndReached={onEndReached} onEndReached={() => void fetchNextPage()} onEndReachedThreshold={2} /> );}
function keyExtractor(item: FeedgenItem) { return isFeedgenSentinel(item) ? item._reactKey : item.uri;}