refactor: migrate messages and notifications lists to the new list state components
35 unmodified lines363738394040414243446 unmodified lines5152535455555657585935 unmodified lines width: '100%', paddingInline: space.lg, '@media': { [desktop]: { width: 450, maxWidth: 450, paddingInline: 0, }, },});6 unmodified lines width: '100%', paddingInline: space.lg, '@media': { [desktop]: { width: 350, maxWidth: 350, paddingInline: 0, }, },});1234345689101112137891718192021222324252627282930313233import { style } from '@vanilla-extract/css';
import { colors } from '#/styles/colors';import { iconSize, space } from '#/styles/tokens.css';import { space } from '#/styles/tokens.css';
export const banner = style({ marginBottom: space.sm });
export const circleInfoIcon = style({ width: iconSize._4xl, height: iconSize._4xl, color: colors.textContrastLow,});
export const empty = style({ justifyContent: 'center', paddingBottom: 40, height: '100%' });
export const emptyTall = style({ justifyContent: 'center', paddingBottom: 120, height: '100%' });
export const errorMessage = style({ paddingBottom: space.xl, maxWidth: 360,});
export const errorTitle = style({ paddingTop: space.md, paddingBottom: space.sm,});
export const errorWrap = style({ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: space._3xl,});17 unmodified lines181920212222232424252626272829303031333233343596 unmodified lines1321331341351381391361371381391401411421431441451461481491501511521531541551561471481491501511521531541551581591601611621631641561571581596 unmodified lines16616716816917917017117218218318418518618718818919019119219319419519619719819917317417517617717817918018118230 unmodified lines213214215216217218219220221242243244222223224225226227251252253254255256257228229230231232233234235236237238239240241144 unmodified linesExpand all17 unmodified linesimport { EmptyState } from '#/components/EmptyState';import { FAB } from '#/components/FAB';import { useRefreshOnFocus } from '#/components/hooks/useRefreshOnFocus';import { List, type ListMethods } from '#/components/List/List';import { ListFooter } from '#/components/Lists';import { ListError } from '#/components/List/ListError';import * as ListTail from '#/components/List/ListTail';import * as Menu from '#/components/Menu';import { Text } from '#/components/Text';import * as Toast from '#/components/Toast';import { Button, ButtonIcon, ButtonText } from '#/components/web/Button';import { Button, ButtonIcon } from '#/components/web/Button';import * as Layout from '#/components/web/Layout';
import NewChatIcon from '#/icons/central-custom/MessagePlus_round_outlined_radius1_stroke2.svg';import RetryIcon from '#/icons/central/ArrowRotateCounterClockwise_round_outlined_radius1_stroke2.svg';import InboxIcon from '#/icons/central/Box2_round_outlined_radius1_stroke2.svg';import CircleCheckIcon from '#/icons/central/CircleCheck_round_outlined_radius1_stroke2.svg';import CircleInfoIcon from '#/icons/central/CircleInfo_round_outlined_radius1_stroke2.svg';import SettingsIcon from '#/icons/central/SettingsGear2_round_outlined_radius1_stroke2.svg';import BubbleSmileIcon from '#/icons/original/BubbleSmile.svg';import { m } from '#/paraglide/messages';import { useFocusEffect, useRouter } from '#/router';96 unmodified lines const openChatControl = () => { newChatHandle.open(null); };
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, isError, error, refetch } = useListConvosQuery({ status: 'accepted' }); const { data, isPending, isFetchingNextPage, fetchNextPage, isError, error, refetch } = useListConvosQuery({ status: 'accepted', });
const { refetch: refetchInbox } = useListConvosQuery({ status: 'request', });
useRefreshOnFocus(refetch); useRefreshOnFocus(refetchInbox);
const conversations: ListItem[] = data?.pages ? data.pages .flatMap((page) => page.convos) .map((convo) => ({ type: 'conversation', conversation: convo, selected: convo.id === selectedChat, })) : []; const conversations = data?.pages .flatMap((page) => page.convos) .map((convo): ListItem => ({ type: 'conversation', conversation: convo, selected: convo.id === selectedChat, })) ?? [];
const onEndReached = () => { if (isFetchingNextPage || !hasNextPage || isError) { return; } void fetchNextPage(); };
useFocusEffect(() => { return softReset.subscribe(() => { scrollElRef.current?.scrollToOffset({ animated: false,6 unmodified lines
if (conversations.length === 0) { return ( <> {isLoading ? ( {isPending ? ( <ChatListLoadingPlaceholder /> ) : isError ? ( <div className={css.errorWrap}> <CircleInfoIcon className={css.circleInfoIcon} /> <Text size="_2xl" weight="semiBold" className={css.errorTitle}> {m['common.error.whoops']()} </Text> <Text size="md" align="center" color="textContrastMedium" className={css.errorMessage}> {cleanError(error) || m['screens.messages.chats.reload.error']()} </Text> <Button label={m['screens.messages.chats.reload.action']()} size="small" color="secondary_inverted" onClick={() => void refetch()} > <ButtonText>{m['common.action.retry']()}</ButtonText> <ButtonIcon icon={RetryIcon} /> </Button> </div> <ListError hideBackButton message={cleanError(error) || m['screens.messages.chats.reload.error']()} onRetry={() => void refetch()} title={m['common.error.whoops']()} /> ) : isWithinSplitView ? ( <EmptyState message={m['screens.messages.chats.inboxEmpty']()} icon={InboxIcon}30 unmodified lines
const list = ( <List ref={scrollElRef} scrollRoot={isWithinSplitView ? scrollContainerRef : undefined} data={conversations} estimateHeight={CHAT_ITEM_HEIGHT_ESTIMATE} renderItem={renderItem} keyExtractor={keyExtractor} onEndReached={onEndReached} onEndReachedThreshold={0} scrollRoot={isWithinSplitView ? scrollContainerRef : undefined} ListHeaderComponent={ chatStatus?.chatDisabled ? ( <ChatDisabled shape="banner" className={isWithinSplitView ? css.banner : undefined} /> ) : undefined } ListFooterComponent={ <ListFooter border={false} error={cleanError(error)} hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onRetry={fetchNextPage} /> <ListTail.Frame border={false}> {isFetchingNextPage ? ( <ListTail.Pending /> ) : isError ? ( <ListTail.Error message={cleanError(error)} onRetry={() => void fetchNextPage()} /> ) : null} </ListTail.Frame> } onEndReached={() => void fetchNextPage()} onEndReachedThreshold={0} /> );
if (isWithinSplitView) {144 unmodified linesExpand all1234537891011121314151617181920212223242526272829import { style } from '@vanilla-extract/css';
import { colors } from '#/styles/colors';import { iconSize, space } from '#/styles/tokens.css';
export const empty = style({ justifyContent: 'center', paddingBottom: 40, height: '100%' });
export const errorMessage = style({ paddingBottom: space.xl, maxWidth: 360,});
export const errorTitle = style({ paddingTop: space.md, paddingBottom: space.sm,});
export const errorWrap = style({ display: 'flex', flexDirection: 'column', alignItems: 'center', paddingTop: space._3xl,});
export const circleInfoIcon = style({ width: iconSize._4xl, height: iconSize._4xl, color: colors.textContrastLow,});12 unmodified lines13141516171817181920212223242425272627282963 unmodified lines939495969910097989910010410510610710810911010111211311411510210311811912012112212312412512612712812913013113213313413510410510610710810911011111211311411511611711811912024 unmodified lines14514614714814915015115215317517617715417918018118218318418515515615715815916016116216316416516616716847 unmodified lines12 unmodified lines
import { EmptyState } from '#/components/EmptyState';import { useRefreshOnFocus } from '#/components/hooks/useRefreshOnFocus';import { List } from '#/components/List/List';import { ListFooter } from '#/components/Lists';import { Text } from '#/components/Text';import { ListError } from '#/components/List/ListError';import * as ListTail from '#/components/List/ListTail';import * as Toast from '#/components/Toast';import { Button, ButtonIcon, ButtonText } from '#/components/web/Button';import * as Layout from '#/components/web/Layout';
import ArrowLeftIcon from '#/icons/central/ArrowLeft_round_outlined_radius1_stroke2.svg';import RetryIcon from '#/icons/central/ArrowRotateCounterClockwise_round_outlined_radius1_stroke2.svg';import InboxLargeIcon from '#/icons/central/Box2_round_outlined_radius1_stroke2.svg';import CheckIcon from '#/icons/central/Checkmark2_round_outlined_radius1_stroke2.svg';import CircleInfoIcon from '#/icons/central/CircleInfo_round_outlined_radius1_stroke2.svg';import { m } from '#/paraglide/messages';import { useRouter } from '#/router';
import { ChatListLoadingPlaceholder } from './components/ChatListLoadingPlaceholder';63 unmodified lines const { isWithinSplitView } = useIsWithinSplitView();
useRequestMessagePollInterval();
const { isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, isError, error, refetch } = listConvosQuery; const { isPending, isFetchingNextPage, fetchNextPage, isError, error, refetch } = listConvosQuery;
useRefreshOnFocus(refetch);
const onEndReached = () => { if (isFetchingNextPage || !hasNextPage || isError) { return; } void fetchNextPage(); };
if (conversations.length < 1) { if (isLoading) { return <ChatListLoadingPlaceholder />; }
if (isError) { return ( <div className={css.errorWrap}> <CircleInfoIcon className={css.circleInfoIcon} /> <Text size="_2xl" weight="semiBold" className={css.errorTitle}> {m['common.error.whoops']()} </Text> <Text size="md" align="center" color="textContrastMedium" className={css.errorMessage}> {cleanError(error) || m['screens.messages.chats.reload.error']()} </Text> <Button label={m['screens.messages.chats.reload.action']()} size="small" color="secondary_inverted" onClick={() => void refetch()} > <ButtonText>{m['common.action.retry']()}</ButtonText> <ButtonIcon icon={RetryIcon} /> </Button> </div> <ListError hideBackButton message={cleanError(error) || m['screens.messages.chats.reload.error']()} onRetry={() => void refetch()} title={m['common.error.whoops']()} /> ); }
if (isPending) { return <ChatListLoadingPlaceholder />; }
return ( <EmptyState message={m['screens.messages.chats.inboxZero']()} icon={InboxLargeIcon}24 unmodified lines }
const list = ( <List scrollRoot={isWithinSplitView ? scrollContainerRef : undefined} data={conversations} estimateHeight={REQUEST_ITEM_HEIGHT_ESTIMATE} renderItem={renderItem} keyExtractor={keyExtractor} onEndReached={onEndReached} onEndReachedThreshold={0} scrollRoot={isWithinSplitView ? scrollContainerRef : undefined} ListFooterComponent={ <ListFooter border={false} error={cleanError(error)} hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onRetry={fetchNextPage} /> <ListTail.Frame border={false}> {isFetchingNextPage ? ( <ListTail.Pending /> ) : isError ? ( <ListTail.Error message={cleanError(error)} onRetry={() => void fetchNextPage()} /> ) : null} </ListTail.Frame> } onEndReached={() => void fetchNextPage()} onEndReachedThreshold={2} /> );
if (isWithinSplitView) {47 unmodified lines23 unmodified lines24252627282829303132219 unmodified linesExpand all252253254255256256257258259260278 unmodified linesExpand all23 unmodified linesimport { ReportConversationDialog } from '#/components/dms/ReportConversationDialog';import { type ConvoWithDetails, type GroupConvoMember, parseConvoView } from '#/components/dms/util';import { Error } from '#/components/Error';import { List } from '#/components/List/List';import { ListFooter } from '#/components/Lists';import * as ListTail from '#/components/List/ListTail';import * as Prompt from '#/components/Prompt';import { Spinner } from '#/components/Spinner';import { Text } from '#/components/Text';import * as Toast from '#/components/Toast';219 unmodified linesExpand all data={items} estimateHeight={76} keyExtractor={keyExtractor} ListHeaderComponent={<SettingsHeader convo={convo} isOwner={isOwner} moderationOpts={moderationOpts} />} ListFooterComponent={<ListFooter border={false} />} ListFooterComponent={<ListTail.Frame border={false} />} renderItem={renderItem} scrollRoot={isWithinSplitView ? scrollContainerRef : undefined} /> );278 unmodified linesExpand all3 unmodified lines45678981191011121314152 unmodified lines1819202122232425262728293031222324256 unmodified lines323334354636373839404142524344545556575859606162636465666768697071727374757677457879808182838485464748492 unmodified lines5253545596979899100101565758596061626364656667686970717273747576777879808182838485868788104105106107108109110111112113114115116117118119120121893 unmodified lines
import { useModerationOpts } from '#/state/moderation/moderation-opts';import { type FeedNotification, useNotificationFeedQuery } from '#/state/queries/notifications/feed';
import { EmptyState } from '#/components/EmptyState';import { ErrorMessage } from '#/components/ErrorMessage';import { List, type ListRef, type ListRenderItemInfo } from '#/components/List/List';import { ListFooter } from '#/components/Lists';import { ListEmpty } from '#/components/List/ListEmpty';import { ListError } from '#/components/List/ListError';import * as ListTail from '#/components/List/ListTail';
import BellIcon from '#/icons/central/Bell_round_outlined_radius1_stroke2.svg';import { m } from '#/paraglide/messages';
2 unmodified linesimport { NotificationFeedLoadingPlaceholder } from './NotificationFeedLoadingPlaceholder';
const NOTIFICATION_ITEM_HEIGHT_ESTIMATE = 120;
const EMPTY_FEED_ITEM = { _reactKey: '__empty__' } as const;const LOADING_ITEM = { _reactKey: '__loading__' } as const;
type NotificationItem = FeedNotification | typeof EMPTY_FEED_ITEM | typeof LOADING_ITEM;type NotificationSentinel = Exclude<NotificationItem, FeedNotification>;
const isNotificationSentinel = (item: NotificationItem): item is NotificationSentinel => { return item === EMPTY_FEED_ITEM || item === LOADING_ITEM;};
export function NotificationFeed({ filter, enabled, scrollElRef,6 unmodified lines onScrolledDownChange: (isScrolledDown: boolean) => void; ListHeaderComponent?: ReactNode;}) { const moderationOpts = useModerationOpts(); const { data, isFetching, isFetched, isError, error, hasNextPage, isFetchingNextPage, fetchNextPage } = const { data, error, fetchNextPage, isError, isFetchingNextPage, isPending, refetch } = useNotificationFeedQuery({ enabled: enabled && !!moderationOpts, filter, });
// check all pages because mentions can leave the first page empty. const isEmpty = !isFetching && !data?.pages.some((page) => page.items.length); const notifications = data?.pages.flatMap((page) => page.items) ?? [];
let items: NotificationItem[] = []; if (isFetched) { if (isEmpty) { items = items.concat([EMPTY_FEED_ITEM]); } else if (data) { for (const page of data.pages) { items = items.concat(page.items); } } } else { items.push(LOADING_ITEM); }
const onEndReached = () => { if (isFetching || !hasNextPage || isError) { return; }
void fetchNextPage(); };
const onPressRetryLoadMore = () => fetchNextPage();
const renderItem = ({ item, index }: ListRenderItemInfo<NotificationItem>) => { const renderItem = ({ item, index }: ListRenderItemInfo<FeedNotification>) => { if (isNotificationSentinel(item)) { if (item === LOADING_ITEM) { return <NotificationFeedLoadingPlaceholder />; } return ( <EmptyState icon={BellIcon} message={m['view.notifications.empty']()} className={css.emptyState} /> ); } return ( <NotificationFeedItem highlightUnread={filter === 'all'} item={item}2 unmodified lines /> ); };
const feedFooter = ( <ListFooter isFetchingNextPage={isFetchingNextPage} error={isError && !isEmpty ? cleanError(error) : undefined} onRetry={onPressRetryLoadMore} hasNextPage={hasNextPage} return ( <List ref={scrollElRef} data={notifications} estimateHeight={NOTIFICATION_ITEM_HEIGHT_ESTIMATE} keyExtractor={(item) => item._reactKey} renderItem={renderItem} ListEmptyComponent={ <> {isError ? ( <ListError hideBackButton message={cleanError(error)} onRetry={() => void refetch()} /> ) : isPending ? ( <NotificationFeedLoadingPlaceholder /> ) : ( <ListEmpty icon={BellIcon} message={m['view.notifications.empty']()} className={css.emptyState} /> )} </> } ListHeaderComponent={ListHeaderComponent} ListFooterComponent={ <ListTail.Frame> {isFetchingNextPage ? ( <ListTail.Pending /> ) : isError && notifications.length > 0 ? ( <ListTail.Error message={cleanError(error)} onRetry={() => void fetchNextPage()} /> ) : null} </ListTail.Frame> } onEndReached={() => void fetchNextPage()} onEndReachedThreshold={2} onScrolledDownChange={onScrolledDownChange} /> );
return ( <> {error && <ErrorMessage message={cleanError(error)} />} <List ref={scrollElRef} data={items} estimateHeight={NOTIFICATION_ITEM_HEIGHT_ESTIMATE} keyExtractor={(item) => item._reactKey} renderItem={renderItem} ListHeaderComponent={ListHeaderComponent} ListFooterComponent={feedFooter} onEndReached={onEndReached} onEndReachedThreshold={2} onScrolledDownChange={onScrolledDownChange} /> </> );}