refactor: migrate the profile collection lists to the new list state components
12343456910117891011131213141516172021222324252627282930181933342021363738394041424344454647484950222324256 unmodified lines3233343565666768697071727336373839404175764243444546474849505180525382838454555657585988899091import type { ReactNode } from 'react';
import type { AppBskyGraphDefs as GraphDefs } from '@atcute/bluesky';
import { cleanError } from '#/lib/errors';
import { type MyListsFilter, useMyListsQuery } from '#/state/queries/my-lists';
import { EmptyState } from '#/components/EmptyState';import { ErrorMessage } from '#/components/ErrorMessage';import { List, type ListRenderItemInfo } from '#/components/List/List';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 * as ListCard from '#/components/ListCard';import { ListFooter } from '#/components/Lists';
import ListIcon from '#/icons/central/BulletList_round_outlined_radius1_stroke2.svg';import { m } from '#/paraglide/messages';
const LIST_ITEM_HEIGHT_ESTIMATE = 120;
const LOADING = { _reactKey: '__loading__' } as const;const EMPTY = { _reactKey: '__empty__' } as const;const ERROR_ITEM = { _reactKey: '__error__' } as const;
type MyListItem = GraphDefs.ListView | typeof EMPTY | typeof ERROR_ITEM | typeof LOADING;type MyListSentinel = Exclude<MyListItem, GraphDefs.ListView>;
const isMyListSentinel = (item: MyListItem): item is MyListSentinel => { return '_reactKey' in item;};
/** Renders the viewer's own lists, one {@link ListCard.Default} row per list. */export function MyLists({ filter }: { filter: MyListsFilter }): ReactNode { const { data, isPending, isError, error, refetch } = useMyListsQuery(filter); const isEmpty = !isPending && !data?.length; const { data, error, isError, isPending, refetch } = useMyListsQuery(filter);
let items: MyListItem[] = []; if (isError && isEmpty) { items = items.concat([ERROR_ITEM]); } if (isPending) { items = items.concat([LOADING]); } else if (isEmpty) { items = items.concat([EMPTY]); } else if (data) { items = items.concat(data); }
// rendering // =
const emptyText = (() => { switch (filter) { case 'curate': { return m['common.list.description']();6 unmodified lines } } })();
const renderItem = ({ index, item }: ListRenderItemInfo<MyListItem>) => { if (isMyListSentinel(item)) { if (item === ERROR_ITEM) { return <ErrorMessage message={cleanError(error)} onPressTryAgain={() => void refetch()} />; } if (item === LOADING) { return <ListCard.LoadingPlaceholder count={10} />; } return <EmptyState icon={ListIcon} iconColor="textContrastMedium" message={emptyText} />; const lists = data ?? [];
if (lists.length < 1) { if (isError) { return <ListError hideBackButton message={cleanError(error)} onRetry={() => void refetch()} />; } return <ListCard.Default topBorder={index !== 0} view={item} />; };
if (isPending) { return <ListCard.LoadingPlaceholder count={10} />; }
return <ListEmpty icon={ListIcon} message={emptyText} />; }
return ( <List data={items} data={lists} estimateHeight={LIST_ITEM_HEIGHT_ESTIMATE} keyExtractor={keyExtractor} renderItem={renderItem} ListFooterComponent={isEmpty ? null : <ListFooter />} keyExtractor={(item) => item.uri} renderItem={({ index, item }) => <ListCard.Default topBorder={index !== 0} view={item} />} ListFooterComponent={<ListTail.Frame />} /> );}
function keyExtractor(item: MyListItem) { return isMyListSentinel(item) ? item._reactKey : item.uri;}12334567810111213149101112131415165 unmodified lines2223242528262731323334353637383940414243444546472829305131323334355736375960613863646566676869707172737475767739407980818283844142434445878889909192939495969798991001011021031041051061071084647109110111112113114115116117118119120121122123124125126481281291301314950515253545556575859606162636465666768697013571721371387374751401411421431441451461471487677150787980818283848586871528889909192931581591601611629495969714 unmodified linesimport type { ReactNode } from 'react';
import type { AnyStarterPackView } from '@atcute/bluesky';import type { Did } from '@atcute/lexicons';
import { cleanError } from '#/lib/errors';
import { useActorStarterPacksQuery } from '#/state/queries/actor-starter-packs';
import { EmptyState } from '#/components/EmptyState';import { ErrorMessage } from '#/components/ErrorMessage';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 { Default as StarterPackCard, LoadingPlaceholder as StarterPackLoadingPlaceholder,} from '#/components/StarterPack/StarterPackCard';5 unmodified linesimport { useRouter } from '#/router';
import * as css from './ProfileStarterPacks.css';
// only governs rows that have never been on screen; the browser reuses the real size once rendered.const STARTER_PACK_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 StarterPackItem = | AnyStarterPackView | typeof EMPTY | typeof ERROR_ITEM | typeof LOADING | typeof LOAD_MORE_ERROR_ITEM;type StarterPackSentinel = Exclude<StarterPackItem, AnyStarterPackView>;
const isStarterPackSentinel = (item: StarterPackItem): item is StarterPackSentinel => { return '_reactKey' in item;};
interface ProfileStarterPacksProps { did: Did; isMe: boolean; /** Known starter-pack count, used to size the loading skeleton; falls back to a small default. */ starterPackCount?: number;}
export function ProfileStarterPacks({ did, isMe, starterPackCount }: ProfileStarterPacksProps): ReactNode { const router = useRouter(); const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage, isError, error, refetch } = const { data, error, fetchNextPage, hasNextPage, isError, isFetchingNextPage, isPending, refetch } = useActorStarterPacksQuery({ did }); const starterPacks = data?.pages.flatMap((page) => page.starterPacks); const hasStarterPacks = !!starterPacks?.length; const isEmpty = !isPending && !hasStarterPacks;
let items: StarterPackItem[] = []; 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.starterPacks); } } else if (isError && !isEmpty) { items = items.concat([LOAD_MORE_ERROR_ITEM]); } const starterPacks = data?.pages.flatMap((page) => page.starterPacks) ?? [];
// events // =
const onEndReached = () => { if (isFetchingNextPage || !hasNextPage || isError) { return; if (starterPacks.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<StarterPackItem>) => { if (isStarterPackSentinel(item)) { if (item === ERROR_ITEM) { return <ErrorMessage message={cleanError(error)} onPressTryAgain={() => void refetch()} />; } if (item === LOAD_MORE_ERROR_ITEM) { return ( <LoadMoreRetryBtn label={m['components.starterPack.list.error']()} onPress={onPressRetryLoadMore} /> ); } if (item === LOADING) { return <StarterPackLoadingPlaceholder count={starterPackCount} />; if (isPending) { return <StarterPackLoadingPlaceholder count={starterPackCount} />; } return ( <EmptyState icon={CircleAndSquareIcon} message={isMe ? m['components.starterPack.list.empty']() : m['common.starterPack.empty']()} button={ isMe ? { label: m['common.starterPack.action.create'](), text: m['common.starterPack.action.create'](), onPress: () => router.navigate({ to: { name: 'StarterPackWizard' } }), size: 'small', color: 'primary', } : undefined } /> ); } // the first starter pack card sits flush under the sticky tab bar; drop its top separator so the two // don't draw a doubled line. return <StarterPackCard starterPack={item} topBorder={index !== 0} />; };
return ( <ListEmpty icon={CircleAndSquareIcon} message={isMe ? m['components.starterPack.list.empty']() : m['common.starterPack.empty']()} button={ isMe ? { label: m['common.starterPack.action.create'](), text: m['common.starterPack.action.create'](), onPress: () => router.navigate({ to: { name: 'StarterPackWizard' } }), size: 'small', color: 'primary', } : undefined } /> ); }
return ( <List data={items} data={starterPacks} estimateHeight={STARTER_PACK_ITEM_HEIGHT_ESTIMATE} keyExtractor={keyExtractor} renderItem={renderItem} keyExtractor={(item) => item.uri} renderItem={({ index, item }) => <StarterPackCard starterPack={item} topBorder={index !== 0} />} ListFooterComponent={ isEmpty ? undefined : hasNextPage || isFetchingNextPage ? ( <ListFooter hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onRetry={fetchNextPage} error={cleanError(error)} height={180} /> ) : isMe && hasStarterPacks ? ( isMe && !hasNextPage && !isFetchingNextPage && !isError ? ( <CreateAnother /> ) : undefined ) : ( <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: StarterPackItem) { return isStarterPackSentinel(item) ? item._reactKey : item.uri;}
/** A footer row offering the profile owner a shortcut back to the starter-pack wizard. */function CreateAnother() { const router = useRouter();
return (14 unmodified lines123345687891213141011121314161715161718192320212627282930313233343536373839404142222345242526275028295253303132335859606162636465666768697071723435747576777879363738394082838485868788899091929394959697989910010141421021031041051061071081091101111121131141151161171181191204312212312412512612712844454647484950515253545556575859606162636465132666713413568697013713813914014114214314414571727374757677781477980818283152153154155import type { ReactNode } from 'react';
import type { AppBskyGraphDefs } from '@atcute/bluesky';import type { Did } from '@atcute/lexicons';
import { cleanError } from '#/lib/errors';
import { usePreferencesQuery } from '#/state/queries/preferences';import { useProfileListsQuery } from '#/state/queries/profile-lists';import { useSession } from '#/state/session';
import { EmptyState } from '#/components/EmptyState';import { ErrorMessage } from '#/components/ErrorMessage';import { List, type ListRenderItemInfo } from '#/components/List/List';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 * as ListCard from '#/components/ListCard';import { ListFooter } from '#/components/Lists';import { LoadMoreRetryBtn } from '#/components/LoadMoreRetryBtn';
import ListIcon from '#/icons/central/BulletList_round_outlined_radius1_stroke2.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 LIST_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 ProfileListItem = | AppBskyGraphDefs.ListView | typeof EMPTY | typeof ERROR_ITEM | typeof LOADING | typeof LOAD_MORE_ERROR_ITEM;type ProfileListSentinel = Exclude<ProfileListItem, AppBskyGraphDefs.ListView>;
const isProfileListSentinel = (item: ProfileListItem): item is ProfileListSentinel => { return '_reactKey' in item;};
interface ProfileListsProps { did: Did; /** Known list count, used to size the loading skeleton; falls back to a small default. */ listCount?: number;}
export function ProfileLists({ did, listCount }: ProfileListsProps): ReactNode { const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage, isError, error, refetch } = const { data, error, fetchNextPage, isError, isFetchingNextPage, isPending, refetch } = useProfileListsQuery(did); const isEmpty = !isPending && !data?.pages.some((page) => page.lists.length); const { data: preferences } = usePreferencesQuery(); const router = useRouter(); const { currentAccount } = useSession(); const isSelf = currentAccount?.did === did;
let items: ProfileListItem[] = []; 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.lists); } } else if (isError && !isEmpty) { items = items.concat([LOAD_MORE_ERROR_ITEM]); } const lists = data?.pages.flatMap((page) => page.lists) ?? [];
// events // =
const onEndReached = () => { if (isFetchingNextPage || !hasNextPage || isError) { return; if (lists.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<ProfileListItem>) => { if (isProfileListSentinel(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 <ListCard.LoadingPlaceholder count={listCount} />; if (isPending) { return <ListCard.LoadingPlaceholder count={listCount} />; } return ( <EmptyState icon={ListIcon} message={isSelf ? m['common.list.empty']() : m['common.list.emptyUser']()} messageColor="textContrastMedium" button={ isSelf ? { label: m['common.list.create'](), text: m['common.list.create'](), onPress: () => router.navigate({ to: { name: 'Lists' } }), size: 'small', color: 'primary', } : undefined } /> ); } if (preferences) { // the first list card sits flush under the sticky tab bar; drop its top separator so the two // don't draw a doubled line. return <ListCard.Default view={item} topBorder={index !== 0} />; } return null; };
return ( <ListEmpty icon={ListIcon} message={isSelf ? m['common.list.empty']() : m['common.list.emptyUser']()} button={ isSelf ? { label: m['common.list.create'](), text: m['common.list.create'](), onPress: () => router.navigate({ to: { name: 'Lists' } }), size: 'small', color: 'primary', } : undefined } /> ); }
return ( <List data={items} data={lists} estimateHeight={LIST_ITEM_HEIGHT_ESTIMATE} keyExtractor={keyExtractor} renderItem={renderItem} keyExtractor={(item) => item.uri} renderItem={({ index, item }) => <ListCard.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: ProfileListItem) { return isProfileListSentinel(item) ? item._reactKey : item.uri;}