refactor: split list loading and error states into distinct components
123456789101112131415161718192021222324252627282930313233343536import type { ReactElement } from 'react';
import { EmptyState, type EmptyStateButtonProps, type EmptyStateIcon } from '#/components/EmptyState';
import { m } from '#/paraglide/messages';
/** * renders an empty list state. * * @param button optional action * @param className container class * @param icon state icon * @param message empty-state message * @returns the empty state */export function ListEmpty({ button, className, icon, message,}: { button?: EmptyStateButtonProps; className?: string; icon?: EmptyStateIcon | ReactElement; message?: string;}) { return ( <EmptyState button={button} className={className} icon={icon} message={message ?? m['common.list.noResults']()} messageColor="textContrastMedium" /> );}123456789101112131415161718192021222324252627282930313233import { Error } from '#/components/Error';
import { m } from '#/paraglide/messages';
/** * renders an initial list error. * * @param hideBackButton whether to hide the back button * @param message error message * @param onRetry retry handler * @param title error title * @returns the error state */export function ListError({ hideBackButton, message, onRetry, title,}: { hideBackButton?: boolean; message?: string; onRetry?: () => void; title?: string;}) { return ( <Error hideBackButton={hideBackButton} message={message || m['common.error.generic']()} onRetry={onRetry} title={title ?? m['common.error.oops']()} /> );}1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556import { createVar, fallbackVar, style } from '@vanilla-extract/css';
import { colors } from '#/styles/colors';import { recipe } from '#/styles/recipe';import { borderRadius, space } from '#/styles/tokens.css';
export const minHeightVar = createVar();
export const frame = recipe( { base: { boxSizing: 'border-box', display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: 30, paddingBottom: space.lg, width: '100%', minHeight: fallbackVar(minHeightVar, '180px'), }, defaultVariants: { border: true }, variants: { border: { false: {}, true: { borderTopWidth: 1, borderTopStyle: 'solid', borderTopColor: colors.borderContrastLow, }, }, }, }, { debugId: 'frame' },);
export const errorOuter = style({ boxSizing: 'border-box', paddingRight: space.lg, paddingLeft: space.lg, width: '100%',});
export const errorRow = style({ display: 'flex', flexDirection: 'row', gap: space.md, alignItems: 'center', borderRadius: borderRadius.sm, backgroundColor: colors.contrast_25, padding: space.md,});
export const errorText = style({ flex: 1, minWidth: 0,});1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465import type { ReactNode } from 'react';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import { Spinner } from '#/components/Spinner';import { Text } from '#/components/Text';import { Button, ButtonText } from '#/components/web/Button';
import { m } from '#/paraglide/messages';
import * as css from './ListTail.css';
/** * reserves space for pagination state. * * @param border whether to show the divider * @param children pagination state * @param minHeight reserved height in pixels * @returns the list tail */export function Frame({ border = true, children, minHeight,}: { border?: boolean; children?: ReactNode; minHeight?: number;}) { return ( <div className={css.frame({ border })} style={minHeight != null ? assignInlineVars({ [css.minHeightVar]: `${minHeight}px` }) : undefined} > {children} </div> );}
/** @returns a pagination spinner */export function Pending() { return <Spinner color="default" label={m['common.status.loading']()} size="_2xl" />;}
/** * renders a pagination error. * * @param message error message * @param onRetry retry handler * @returns the error row */export function Error({ message, onRetry }: { message: string; onRetry: () => void }) { return ( <div className={css.errorOuter}> <div className={css.errorRow}> <Text className={css.errorText} color="textContrastMedium" numberOfLines={2} size="sm"> {message} </Text> <Button label={m['common.a11y.pressToRetry']()} onClick={onRetry} variant="solid"> <ButtonText>{m['common.action.retry']()}</ButtonText> </Button> </div> </div> );}1234567891091111121314151617189 unmodified lines2829303129303132333435363233343510 unmodified lines46474849555657585960505152534 unmodified lines58596061736263646575666777787968697071727374757677787980818283848586878889818283909192939485868788899091929394959697989910010110210310410510610710810995969711111298991001011021031041051061071081091101111121131141151161 unmodified line11811912012112212312412512612712212312412512612712812913013113213313413513513613613713813948 unmodified lines188189190191193194195196197198199200201202203204205206207208209210211212import type { AppBskyBookmarkDefs, AppBskyFeedDefs } from '@atcute/bluesky';import type { $type } from '@atcute/lexicons';
import { mapDefined } from '@mary/array-fns';
import { cleanError } from '#/lib/errors';
import { useBookmarkMutation } from '#/state/queries/bookmarks/useBookmarkMutation';import { useBookmarksQuery } from '#/state/queries/bookmarks/useBookmarksQuery';
import { EmptyState } from '#/components/EmptyState';import { List, type ListRenderItemInfo } from '#/components/List/List';import { ListFooter, ListMaybePlaceholder } from '#/components/Lists';import { ListEmpty } from '#/components/List/ListEmpty';import { ListError } from '#/components/List/ListError';import * as ListTail from '#/components/List/ListTail';import { Post } from '#/components/Post/Post';import { PostFeedLoadingPlaceholder } from '#/components/PostFeed/PostFeedLoadingPlaceholder';import { Text } from '#/components/Text';import * as toast from '#/components/Toast';9 unmodified linesimport * as css from './Bookmarks.css';
type ListItem = | { type: 'loading'; key: 'loading'; } | { type: 'empty'; key: 'empty'; } | { type: 'bookmark'; key: string; bookmark: Omit<AppBskyBookmarkDefs.BookmarkView, 'item'> & { item: $type.enforce<AppBskyFeedDefs.PostView>;10 unmodified linesconst BOOKMARK_ITEM_HEIGHT_ESTIMATE = 300;
function renderItem({ index, item }: ListRenderItemInfo<ListItem>) { switch (item.type) { case 'loading': { return <PostFeedLoadingPlaceholder />; } case 'empty': { return <BookmarksEmpty />; } case 'bookmark': { return <BookmarkItem item={item} hideTopBorder={index === 0} />; } case 'bookmarkNotFound': {4 unmodified lines } }}
/** renders saved posts. */const keyExtractor = (item: ListItem) => item.key;
export function BookmarksTab() { const router = useRouter(); const { data, error, fetchNextPage, isError, isFetchingNextPage, isLoading, refetch } = useBookmarksQuery(); const { data, error, fetchNextPage, isError, isFetchingNextPage, isPending, refetch } = useBookmarksQuery();
if (isError && !data) { return <ListMaybePlaceholder isLoading={false} isError onRetry={refetch} />; } const items = data?.pages.flatMap((page) => { return mapDefined(page.bookmarks, ({ item, subject, createdAt }): ListItem | undefined => { switch (item.$type) { case 'app.bsky.feed.defs#notFoundPost': { return { type: 'bookmarkNotFound', key: item.uri, bookmark: { item, subject, createdAt }, }; } case 'app.bsky.feed.defs#postView': { return { type: 'bookmark', key: item.uri, bookmark: { item, subject, createdAt }, }; } } }); }) ?? [];
const items: ListItem[] = []; if (data) { const bookmarks = data.pages.flatMap((p) => p.bookmarks); if (items.length < 1) { if (isError) { return <ListError message={cleanError(error)} onRetry={() => void refetch()} />; }
if (bookmarks.length > 0) { for (const bookmark of bookmarks) { if (bookmark.item.$type === 'app.bsky.feed.defs#notFoundPost') { items.push({ type: 'bookmarkNotFound', key: bookmark.item.uri, bookmark: { ...bookmark, item: bookmark.item, }, }); } if (bookmark.item.$type === 'app.bsky.feed.defs#postView') { items.push({ type: 'bookmark', key: bookmark.item.uri, bookmark: { ...bookmark, item: bookmark.item, }, }); } } } else { items.push({ type: 'empty', key: 'empty' }); if (isPending) { return <PostFeedLoadingPlaceholder />; } } else if (isLoading) { items.push({ type: 'loading', key: 'loading' });
return ( <ListEmpty className={css.empty} icon={BookmarkDeleteLarge} message={m['screens.bookmarks.empty']()} button={{ label: m['screens.bookmarks.backHome'](), text: m['common.action.goHome'](), onPress: () => router.navigate({ to: { name: 'Home' } }), size: 'small', color: 'secondary', }} /> ); }
return ( <List1 unmodified line estimateHeight={BOOKMARK_ITEM_HEIGHT_ESTIMATE} keyExtractor={keyExtractor} renderItem={renderItem} ListFooterComponent={ <ListFooter border={items.length > 0} error={cleanError(error)} isFetchingNextPage={isFetchingNextPage} onRetry={fetchNextPage} /> <ListTail.Frame> {isFetchingNextPage ? ( <ListTail.Pending /> ) : isError ? ( <ListTail.Error message={cleanError(error)} onRetry={() => void fetchNextPage()} /> ) : null} </ListTail.Frame> } onEndReached={() => void fetchNextPage()} onEndReachedThreshold={2} /> );}
const keyExtractor = (item: ListItem) => item.key;
function BookmarkNotFound({ hideTopBorder, post,}: {48 unmodified lines hideTopBorder: boolean;}) { return <Post post={item.bookmark.item} hideTopBorder={hideTopBorder} />;}
function BookmarksEmpty() { const router = useRouter();
return ( <EmptyState icon={BookmarkDeleteLarge} message={m['screens.bookmarks.empty']()} messageColor="textContrastMedium" button={{ label: m['screens.bookmarks.backHome'](), text: m['common.action.goHome'](), onPress: () => router.navigate({ to: { name: 'Home' } }), size: 'small', color: 'secondary', }} className={css.empty} /> );}1234567689101112131415161718141920211617181920212223242526222324253031322627282934353037383940414243444546473132333435363738394041545542434458596061624546474849505152645354555657import type { AppBskyFeedDefs } from '@atcute/bluesky';
import { cleanError } from '#/lib/errors';
import { useMyLikesQuery } from '#/state/queries/my-likes';
import { List } from '#/components/List/List';import { ListFooter, ListMaybePlaceholder } from '#/components/Lists';import { ListEmpty } from '#/components/List/ListEmpty';import { ListError } from '#/components/List/ListError';import * as ListTail from '#/components/List/ListTail';import { Post } from '#/components/Post/Post';import { PostFeedLoadingPlaceholder } from '#/components/PostFeed/PostFeedLoadingPlaceholder';
import HeartIcon from '#/icons/central/Heart_round_outlined_radius1_stroke1.svg';import { m } from '#/paraglide/messages';
const LIKE_ITEM_HEIGHT_ESTIMATE = 300;
/** renders liked posts. */const keyExtractor = (item: AppBskyFeedDefs.PostView) => `${item.uri}|${item.cid}`;
export function LikesTab() { const { data, error, fetchNextPage, hasNextPage, isError, isFetched, isFetchingNextPage, isLoading, refetch, } = useMyLikesQuery(); const { data, error, fetchNextPage, isError, isFetchingNextPage, isPending, refetch } = useMyLikesQuery();
const posts = data?.pages.flatMap((page) => page.feed.map((item) => item.post)) ?? [];
const onEndReached = () => { if (isFetchingNextPage || !hasNextPage || error) { return; if (posts.length < 1) { if (isError) { return <ListError message={cleanError(error)} onRetry={() => void refetch()} />; } void fetchNextPage(); };
if (posts.length < 1) { return ( <ListMaybePlaceholder isLoading={isLoading || !isFetched} isError={isError} onRetry={refetch} emptyMessage={m['common.like.empty']()} emptyStateIcon={HeartIcon} useEmptyState /> ); if (isPending) { return <PostFeedLoadingPlaceholder />; }
return <ListEmpty icon={HeartIcon} message={m['common.like.empty']()} />; }
return ( <List data={posts} estimateHeight={LIKE_ITEM_HEIGHT_ESTIMATE} // cursor pages can overlap. keyExtractor={(item, index) => `${item.uri}-${index}`} keyExtractor={keyExtractor} renderItem={({ index, item }) => <Post hideTopBorder={index === 0} post={item} />} ListFooterComponent={ <ListFooter error={cleanError(error)} isFetchingNextPage={isFetchingNextPage} onRetry={fetchNextPage} /> <ListTail.Frame> {isFetchingNextPage ? ( <ListTail.Pending /> ) : isError ? ( <ListTail.Error message={cleanError(error)} onRetry={() => void fetchNextPage()} /> ) : null} </ListTail.Frame> } onEndReached={onEndReached} onEndReached={() => void fetchNextPage()} onEndReachedThreshold={2} /> );}