pl-fe: move ChatList to @tanstack/virtual, some fixes

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-09-19 15:15:02 +02:00
parent 1727dc4e1b
commit aadd9439aa
14 changed files with 108 additions and 101 deletions

View file

@ -8,6 +8,15 @@ import { useSettings } from 'pl-fe/hooks';
import LoadMore from './load-more';
import { Card, Spinner } from './ui';
type IScrollableListWindowScroll = {
/** Whether to use the window to scroll the content instead of the container. */
useWindowScroll?: true;
} | {
/** Whether to use the window to scroll the content instead of the container. */
useWindowScroll: false;
parentRef: React.RefObject<HTMLElement>;
};
interface IScrollableList {
/** Pagination callback when the end of the list is reached. */
onLoadMore?: () => void;
@ -27,10 +36,8 @@ interface IScrollableList {
emptyMessageCard?: boolean;
/** Scrollable content. */
children: Iterable<React.ReactNode>;
/** Callback when the list is scrolled to the top. */
onScrollToTop?: () => void;
/** Callback when the list is scrolled. */
onScroll?: () => void;
onScroll?: (startIndex?: number, endIndex?: number) => void;
/** Placeholder component to render while loading. */
placeholderComponent?: React.ComponentType | React.NamedExoticComponent;
/** Number of placeholders to render while loading. */
@ -47,15 +54,13 @@ interface IScrollableList {
id?: string;
/** CSS styles on the parent element. */
style?: React.CSSProperties;
/** Whether to use the window to scroll the content instead of the container. */
useWindowScroll?: boolean;
/** Initial item index to scroll to. */
initialIndex?: number;
/** Estimated size for items */
estimatedSize?: number;
}
const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList>(({
const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList & IScrollableListWindowScroll>(({
prepend = null,
alwaysPrepend,
children,
@ -64,7 +69,6 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList>(
emptyMessageCard = true,
showLoading,
onScroll,
onScrollToTop,
onLoadMore,
className,
listClassName,
@ -76,8 +80,8 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList>(
placeholderCount = 0,
initialIndex = 0,
style = {},
useWindowScroll = true,
estimatedSize = 300,
...props
}, ref) => {
const { autoloadMore } = useSettings();
@ -90,15 +94,15 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList>(
const data = showPlaceholder ? Array(placeholderCount).fill('') : elements;
const virtualizer = useWindowScroll ? useWindowVirtualizer({
const virtualizer = props.useWindowScroll === false ? useVirtualizer({
count: data.length + (hasMore ? 1 : 0),
overscan: 3,
estimateSize: () => estimatedSize,
}) : useVirtualizer({
getScrollElement: () => props.parentRef.current || parentRef.current,
}) : useWindowVirtualizer({
count: data.length + (hasMore ? 1 : 0),
overscan: 3,
estimateSize: () => estimatedSize,
getScrollElement: () => parentRef.current,
});
useEffect(() => {
@ -114,10 +118,8 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList>(
}, [showLoading, initialIndex]);
useEffect(() => {
if (range?.startIndex === 0) {
onScrollToTop?.();
} else onScroll?.();
}, [range?.startIndex === 0]);
onScroll?.(range?.startIndex, range?.endIndex);
}, [range?.startIndex, range?.endIndex]);
useEffect(() => {
if (onLoadMore && range?.endIndex === data.length && !showLoading && autoloadMore && hasMore) {

View file

@ -1,9 +1,9 @@
import clsx from 'clsx';
import React, { useRef, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import React, { useState } from 'react';
import PullToRefresh from 'pl-fe/components/pull-to-refresh';
import { Spinner, Stack } from 'pl-fe/components/ui';
import ScrollableList from 'pl-fe/components/scrollable-list';
import { Stack } from 'pl-fe/components/ui';
import PlaceholderChat from 'pl-fe/features/placeholder/components/placeholder-chat';
import { useChats } from 'pl-fe/queries/chats';
@ -11,12 +11,11 @@ import ChatListItem from './chat-list-item';
interface IChatList {
onClickChat: (chat: any) => void;
useWindowScroll?: boolean;
parentRef: React.RefObject<HTMLElement>;
topOffset: number;
}
const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false }) => {
const chatListRef = useRef(null);
const ChatList: React.FC<IChatList> = ({ onClickChat, parentRef, topOffset }) => {
const { chatsQuery: { data: chats, isFetching, hasNextPage, fetchNextPage, refetch } } = useChats();
const [isNearBottom, setNearBottom] = useState<boolean>(false);
@ -45,43 +44,48 @@ const ChatList: React.FC<IChatList> = ({ onClickChat, useWindowScroll = false })
};
return (
<div className='relative h-full'>
<PullToRefresh onRefresh={handleRefresh}>
<Virtuoso
ref={chatListRef}
atTopStateChange={(atTop) => setNearTop(atTop)}
atBottomStateChange={(atBottom) => setNearBottom(atBottom)}
useWindowScroll={useWindowScroll}
data={chats}
endReached={handleLoadMore}
itemContent={(_index, chat) => (
<div className='px-2'>
<ChatListItem chat={chat} onClick={onClickChat} />
</div>
)}
components={{
ScrollSeekPlaceholder: () => <PlaceholderChat />,
Footer: () => hasNextPage ? <Spinner withText={false} /> : null,
EmptyPlaceholder: renderEmpty,
}}
/>
</PullToRefresh>
<>
<div className='relative h-full'>
<PullToRefresh onRefresh={handleRefresh}>
<ScrollableList
onScroll={(top, bottom) => {
setNearTop(top === 0);
setNearBottom(bottom === chats?.length);
}}
itemClassName='px-2'
emptyMessage={renderEmpty()}
placeholderComponent={PlaceholderChat}
placeholderCount={3}
hasMore={hasNextPage}
onLoadMore={handleLoadMore}
estimatedSize={64}
useWindowScroll={false}
parentRef={parentRef}
loadMoreClassName='mx-4 mb-4'
>
{(chats || []).map(chat => (
<ChatListItem key={chat.id} chat={chat} onClick={onClickChat} />
))}
</ScrollableList>
</PullToRefresh>
<>
<div
className={clsx('pointer-events-none absolute inset-x-0 top-0 flex justify-center rounded-t-lg bg-gradient-to-b from-white to-transparent pb-12 pt-8 transition-opacity duration-500 dark:from-gray-900', {
'opacity-0': isNearTop,
'opacity-100 black:opacity-50': !isNearTop,
})}
/>
<div
className={clsx('pointer-events-none absolute inset-x-0 bottom-0 flex justify-center rounded-b-lg bg-gradient-to-t from-white to-transparent pb-8 pt-12 transition-opacity duration-500 dark:from-gray-900', {
'opacity-0': isNearBottom,
'opacity-100 black:opacity-50': !isNearBottom,
})}
/>
</>
</div>
</div>
<div
className={clsx('pointer-events-none absolute inset-x-0 flex justify-center rounded-t-lg bg-gradient-to-b from-white to-transparent pb-12 pt-8 transition-opacity duration-500 black:from-black dark:from-gray-900', {
'opacity-0': isNearTop,
'opacity-100 black:opacity-50': !isNearTop,
})}
style={{
top: topOffset,
}}
/>
<div
className={clsx('pointer-events-none absolute inset-x-0 bottom-0 flex justify-center rounded-b-lg bg-gradient-to-t from-white to-transparent pb-8 pt-12 transition-opacity duration-500 black:from-black dark:from-gray-900', {
'opacity-0': isNearBottom,
'opacity-100 black:opacity-50': !isNearBottom,
})}
/>
</>
);
};

View file

@ -1,6 +1,5 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { __stub } from 'pl-fe/api';
import { ChatContext } from 'pl-fe/contexts/chat-context';
@ -62,11 +61,11 @@ const store = rootState
.set('instance', buildInstance({ version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)' }));
const renderComponentWithChatContext = () => render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<ChatContext.Provider value={{ chat }}>
<ChatMessageList chat={chat} />
</ChatContext.Provider>
</VirtuosoMockContext.Provider>,
// <VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<ChatContext.Provider value={{ chat }}>
<ChatMessageList chat={chat} />
</ChatContext.Provider>,
// </VirtuosoMockContext.Provider>,
undefined,
store,
);

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
@ -15,6 +15,7 @@ const messages = defineMessages({
const ChatPageSidebar = () => {
const intl = useIntl();
const history = useHistory();
const listRef = useRef<HTMLDivElement>(null);
const handleClickChat = (chat: Chat) => {
history.push(`/chats/${chat.id}`);
@ -29,7 +30,7 @@ const ChatPageSidebar = () => {
};
return (
<Stack space={4} className='h-full'>
<Stack space={4} className='h-full relative'>
<Stack space={4} className='px-4 pt-6'>
<HStack alignItems='center' justifyContent='between'>
<CardTitle title={intl.formatMessage(messages.title)} />
@ -50,8 +51,8 @@ const ChatPageSidebar = () => {
</HStack>
</Stack>
<Stack className='h-full grow'>
<ChatList onClickChat={handleClickChat} />
<Stack className='h-full grow overflow-auto' ref={listRef}>
<ChatList onClickChat={handleClickChat} parentRef={listRef} topOffset={68} />
</Stack>
</Stack>
);

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { Stack } from 'pl-fe/components/ui';
@ -19,6 +19,8 @@ import Blankslate from './blankslate';
import type { Chat } from 'pl-api';
const ChatPane = () => {
const ref = useRef<HTMLDivElement>(null);
const { unreadChatsCount } = useStatContext();
const { screen, changeScreen, isOpen, toggleChatPane } = useChatContext();
@ -31,11 +33,9 @@ const ChatPane = () => {
const renderBody = () => {
if (Number(chats?.length) > 0 || isLoading) {
return (
<Stack space={4} className='h-full grow'>
<Stack space={4} className='h-full grow overflow-auto' ref={ref}>
{(Number(chats?.length) > 0 || isLoading) ? (
<ChatList
onClickChat={handleClickChat}
/>
<ChatList onClickChat={handleClickChat} parentRef={ref} topOffset={64} />
) : (
<EmptyResultsBlankslate />
)}

View file

@ -48,7 +48,7 @@ const LandingTimeline = () => {
{timelineEnabled ? (
<PullToRefresh onRefresh={handleRefresh}>
<Timeline
className='black:p-0 black:sm:p-4'
listClassName='black:p-0 black:sm:p-4'
loadMoreClassName='black:sm:mx-4'
scrollKey={`${timelineId}_timeline`}
timelineId={timelineId}

View file

@ -56,12 +56,8 @@ const Notifications = () => {
dispatch(expandNotifications({ maxId: last && last.id }));
}, 300, { leading: true }), [notifications]);
const handleScrollToTop = useCallback(debounce(() => {
dispatch(scrollTopNotifications(true));
}, 100), []);
const handleScroll = useCallback(debounce(() => {
dispatch(scrollTopNotifications(false));
const handleScroll = useCallback(debounce((startIndex?: number) => {
dispatch(scrollTopNotifications(startIndex === 0));
}, 100), []);
const handleMoveUp = (id: string) => {
@ -93,7 +89,6 @@ const Notifications = () => {
return () => {
handleLoadOlder.cancel();
handleScrollToTop.cancel();
handleScroll.cancel();
dispatch(scrollTopNotifications(false));
};
@ -135,7 +130,6 @@ const Notifications = () => {
placeholderComponent={PlaceholderNotification}
placeholderCount={20}
onLoadMore={handleLoadOlder}
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
listClassName={clsx('divide-y divide-solid divide-gray-200 black:divide-gray-800 dark:divide-primary-800', {
'animate-pulse': notifications.size === 0,

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { BigCard } from 'pl-fe/components/big-card';
@ -8,6 +8,7 @@ import AccountContainer from 'pl-fe/containers/account-container';
import { useOnboardingSuggestions } from 'pl-fe/queries/suggestions';
const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
const parentRef = useRef<HTMLDivElement>(null);
const { data, isFetching } = useOnboardingSuggestions();
const renderSuggestions = () => {
@ -16,11 +17,12 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => {
}
return (
<div className='flex flex-col sm:pb-10 sm:pt-4'>
<div className='flex flex-col sm:pb-10 sm:pt-4' ref={parentRef}>
<ScrollableList
isLoading={isFetching}
useWindowScroll={false}
style={{ height: 320 }}
useWindowScroll={false}
parentRef={parentRef}
>
{data.map((suggestion) => (
<div key={suggestion.account.id} className='py-2'>

View file

@ -400,13 +400,14 @@ const Thread: React.FC<IThread> = ({
ref={virtualizer}
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
initialIndex={initialIndex}
useWindowScroll={useWindowScroll}
itemClassName={itemClassName}
listClassName={
clsx({
'h-full': !useWindowScroll,
})
}
useWindowScroll={useWindowScroll}
parentRef={node}
>
{children}
</ScrollableList>

View file

@ -1,5 +1,5 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import React from 'react';
import React, { useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import ScrollableList from 'pl-fe/components/scrollable-list';
@ -17,6 +17,7 @@ interface FamiliarFollowersModalProps {
}
const FamiliarFollowersModal: React.FC<BaseModalProps & FamiliarFollowersModalProps> = ({ accountId, onClose }) => {
const modalRef = useRef<HTMLDivElement>(null);
const account = useAppSelector(state => getAccount(state, accountId));
const familiarFollowerIds: ImmutableOrderedSet<string> = useAppSelector(state => state.user_lists.familiar_followers.get(accountId)?.items || ImmutableOrderedSet());
@ -36,8 +37,9 @@ const FamiliarFollowersModal: React.FC<BaseModalProps & FamiliarFollowersModalPr
emptyMessage={emptyMessage}
itemClassName='pb-3'
style={{ height: 'calc(80vh - 88px)' }}
useWindowScroll={false}
estimatedSize={42}
useWindowScroll={false}
parentRef={modalRef}
>
{familiarFollowerIds.map(id =>
<AccountContainer key={id} id={id} />,

View file

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchFavourites, expandFavourites } from 'pl-fe/actions/interactions';
@ -14,6 +14,7 @@ interface FavouritesModalProps {
}
const FavouritesModal: React.FC<BaseModalProps & FavouritesModalProps> = ({ onClose, statusId }) => {
const modalRef = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
const accountIds = useAppSelector((state) => state.user_lists.favourited_by.get(statusId)?.items);
@ -50,10 +51,11 @@ const FavouritesModal: React.FC<BaseModalProps & FavouritesModalProps> = ({ onCl
listClassName='max-w-full'
itemClassName='pb-3'
style={{ height: 'calc(80vh - 88px)' }}
useWindowScroll={false}
onLoadMore={handleLoadMore}
hasMore={!!next}
estimatedSize={42}
useWindowScroll={false}
parentRef={modalRef}
>
{accountIds.map(id =>
<AccountContainer key={id} id={id} />,

View file

@ -1,6 +1,6 @@
import clsx from 'clsx';
import { List as ImmutableList } from 'immutable';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { fetchReactions } from 'pl-fe/actions/interactions';
@ -28,6 +28,7 @@ interface ReactionsModalProps {
}
const ReactionsModal: React.FC<BaseModalProps & ReactionsModalProps> = ({ onClose, statusId, reaction: initialReaction }) => {
const modalRef = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
const intl = useIntl();
const [reaction, setReaction] = useState(initialReaction);
@ -93,8 +94,9 @@ const ReactionsModal: React.FC<BaseModalProps & ReactionsModalProps> = ({ onClos
listClassName='max-w-full'
itemClassName='pb-3'
style={{ height: 'calc(80vh - 88px)' }}
useWindowScroll={false}
estimatedSize={42}
useWindowScroll={false}
parentRef={modalRef}
>
{accounts.map((account) =>
<AccountContainer key={`${account.id}-${account.reaction}`} id={account.id} emoji={account.reaction} emojiUrl={account.reactionUrl} />,

View file

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { fetchReblogs, expandReblogs } from 'pl-fe/actions/interactions';
@ -19,6 +19,7 @@ const ReblogsModal: React.FC<BaseModalProps & ReblogsModalProps> = ({ onClose, s
const intl = useIntl();
const accountIds = useAppSelector((state) => state.user_lists.reblogged_by.get(statusId)?.items);
const next = useAppSelector((state) => state.user_lists.reblogged_by.get(statusId)?.next);
const modalRef = useRef<HTMLDivElement>(null);
const fetchData = () => {
dispatch(fetchReblogs(statusId));
@ -52,10 +53,11 @@ const ReblogsModal: React.FC<BaseModalProps & ReblogsModalProps> = ({ onClose, s
listClassName='max-w-full'
itemClassName='pb-3'
style={{ height: 'calc(80vh - 88px)' }}
useWindowScroll={false}
onLoadMore={handleLoadMore}
hasMore={!!next}
estimatedSize={42}
useWindowScroll={false}
parentRef={modalRef}
>
{accountIds.map((id) =>
<AccountContainer key={id} id={id} />,
@ -68,6 +70,7 @@ const ReblogsModal: React.FC<BaseModalProps & ReblogsModalProps> = ({ onClose, s
<Modal
title={<FormattedMessage id='column.reblogs' defaultMessage='Reposts' />}
onClose={onClickClose}
ref={modalRef}
>
{body}
</Modal>

View file

@ -44,12 +44,8 @@ const Timeline: React.FC<ITimeline> = ({
dispatch(dequeueTimeline(timelineId, onLoadMore));
}, []);
const handleScrollToTop = useCallback(debounce(() => {
dispatch(scrollTopTimeline(timelineId, true));
}, 100), [timelineId]);
const handleScroll = useCallback(debounce(() => {
dispatch(scrollTopTimeline(timelineId, false));
const handleScroll = useCallback(debounce((startIndex?: number) => {
dispatch(scrollTopTimeline(timelineId, startIndex === 0));
}, 100), [timelineId]);
return (
@ -65,7 +61,6 @@ const Timeline: React.FC<ITimeline> = ({
<StatusList
timelineId={timelineId}
onScrollToTop={handleScrollToTop}
onScroll={handleScroll}
lastStatusId={lastStatusId}
statusIds={statusIds}