pl-fe: Migrate ChatMessageList to @tanstack/virtual

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-09-19 20:35:26 +02:00
parent 58a7122552
commit 4965cee37b
11 changed files with 94 additions and 91 deletions

View file

@ -56,8 +56,10 @@ interface IScrollableList {
style?: React.CSSProperties; style?: React.CSSProperties;
/** Initial item index to scroll to. */ /** Initial item index to scroll to. */
initialIndex?: number; initialIndex?: number;
/** Estimated size for items */ /** Estimated size for items. */
estimatedSize?: number; estimatedSize?: number;
/** Align the items to the bottom of the list. */
alignToBottom?: boolean;
} }
const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList & IScrollableListWindowScroll>(({ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList & IScrollableListWindowScroll>(({
@ -81,6 +83,7 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList &
initialIndex = 0, initialIndex = 0,
style = {}, style = {},
estimatedSize = 300, estimatedSize = 300,
alignToBottom,
...props ...props
}, ref) => { }, ref) => {
const { autoloadMore } = useSettings(); const { autoloadMore } = useSettings();
@ -160,9 +163,9 @@ const ScrollableList = React.forwardRef<Virtualizer<any, any>, IScrollableList &
const renderItem = (index: number): JSX.Element => { const renderItem = (index: number): JSX.Element => {
const PlaceholderComponent = Placeholder || Spinner; const PlaceholderComponent = Placeholder || Spinner;
if (index === data.length) return (isLoading) ? <PlaceholderComponent /> : loadMore || <div className='h-4' />; if (alignToBottom && hasMore ? index === 0 : index === data.length) return (isLoading) ? <PlaceholderComponent /> : loadMore || <div className='h-4' />;
if (showPlaceholder) return <PlaceholderComponent />; if (showPlaceholder) return <PlaceholderComponent />;
return data[index]; return data[alignToBottom && hasMore ? index - 1 : index];
}; };
const virtualItems = virtualizer.getVirtualItems(); const virtualItems = virtualizer.getVirtualItems();

View file

@ -61,11 +61,9 @@ const store = rootState
.set('instance', buildInstance({ version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)' })); .set('instance', buildInstance({ version: '3.4.1 (compatible; TruthSocial 1.0.0+unreleased)' }));
const renderComponentWithChatContext = () => render( const renderComponentWithChatContext = () => render(
// <VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<ChatContext.Provider value={{ chat }}> <ChatContext.Provider value={{ chat }}>
<ChatMessageList chat={chat} /> <ChatMessageList chat={chat} />
</ChatContext.Provider>, </ChatContext.Provider>,
// </VirtuosoMockContext.Provider>,
undefined, undefined,
store, store,
); );

View file

@ -1,8 +1,8 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useIntl, defineMessages } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import { Components, Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Avatar, Button, Divider, Spinner, Stack, Text } from 'pl-fe/components/ui'; import ScrollableList from 'pl-fe/components/scrollable-list';
import { Avatar, Button, Divider, Stack, Text } from 'pl-fe/components/ui';
import PlaceholderChatMessage from 'pl-fe/features/placeholder/components/placeholder-chat-message'; import PlaceholderChatMessage from 'pl-fe/features/placeholder/components/placeholder-chat-message';
import { useAppSelector } from 'pl-fe/hooks'; import { useAppSelector } from 'pl-fe/hooks';
import { useChatActions, useChatMessages } from 'pl-fe/queries/chats'; import { useChatActions, useChatMessages } from 'pl-fe/queries/chats';
@ -36,25 +36,25 @@ const timeChange = (prev: Pick<ChatMessageEntity, 'created_at'>, curr: Pick<Chat
const START_INDEX = 10000; const START_INDEX = 10000;
const List: Components['List'] = React.forwardRef((props, ref) => { // const List: Components['List'] = React.forwardRef((props, ref) => {
const { context, ...rest } = props; // const { context, ...rest } = props;
return <div ref={ref} {...rest} className='mb-2' />; // return <div ref={ref} {...rest} className='mb-2' />;
}); // });
const Scroller: Components['Scroller'] = React.forwardRef((props, ref) => { // const Scroller: Components['Scroller'] = React.forwardRef((props, ref) => {
const { style, context, ...rest } = props; // const { style, context, ...rest } = props;
return ( // return (
<div // <div
{...rest} // {...rest}
ref={ref} // ref={ref}
style={{ // style={{
...style, // ...style,
scrollbarGutter: 'stable', // scrollbarGutter: 'stable',
}} // }}
/> // />
); // );
}); // });
interface IChatMessageList { interface IChatMessageList {
/** Chat the messages are being rendered from. */ /** Chat the messages are being rendered from. */
@ -65,7 +65,7 @@ interface IChatMessageList {
const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => { const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
const intl = useIntl(); const intl = useIntl();
const node = useRef<VirtuosoHandle>(null); const parentRef = useRef<HTMLDivElement>(null);
const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20); const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20);
const { markChatAsRead } = useChatActions(chat.id); const { markChatAsRead } = useChatActions(chat.id);
@ -137,16 +137,16 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
}; };
const cachedChatMessages = buildCachedMessages(); const cachedChatMessages = buildCachedMessages();
const initialScrollPositionProps = useMemo(() => { // const initialScrollPositionProps = useMemo(() => {
if (process.env.NODE_ENV === 'test') { // if (process.env.NODE_ENV === 'test') {
return {}; // return {};
} // }
return { // return {
initialTopMostItemIndex: cachedChatMessages.length - 1, // initialTopMostItemIndex: cachedChatMessages.length - 1,
firstItemIndex: Math.max(0, firstItemIndex), // firstItemIndex: Math.max(0, firstItemIndex),
}; // };
}, [cachedChatMessages.length, firstItemIndex]); // }, [cachedChatMessages.length, firstItemIndex]);
const handleStartReached = useCallback(() => { const handleStartReached = useCallback(() => {
if (hasNextPage && !isFetching) { if (hasNextPage && !isFetching) {
@ -231,14 +231,30 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
} }
return ( return (
<div className='flex h-full grow flex-col space-y-6'> <div className='flex h-full grow flex-col space-y-6 overflow-auto' style={{ scrollbarGutter: 'auto' }}>
<div className='flex grow flex-col justify-end'> <div className='flex grow flex-col justify-end' ref={parentRef}>
<Virtuoso <ScrollableList
ref={node} listClassName='mb-2'
loadMoreClassName='w-fit mx-auto mb-2'
alignToBottom
initialIndex={cachedChatMessages.length - 1}
hasMore={hasNextPage}
isLoading={isFetching}
showLoading={isFetching && !isFetchingNextPage}
onLoadMore={handleStartReached}
useWindowScroll={false}
parentRef={parentRef}
>
{cachedChatMessages.map((chatMessage, index) => {
if (chatMessage.type === 'divider') {
return renderDivider(index, chatMessage.text);
} else {
return <ChatMessage chat={chat} chatMessage={chatMessage} />;
}
})}
</ScrollableList>
{/* <Virtuoso
alignToBottom alignToBottom
{...initialScrollPositionProps}
data={cachedChatMessages}
startReached={handleStartReached}
followOutput='auto' followOutput='auto'
itemContent={(index, chatMessage) => { itemContent={(index, chatMessage) => {
if (chatMessage.type === 'divider') { if (chatMessage.type === 'divider') {
@ -258,7 +274,7 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
return null; return null;
}, },
}} }}
/> /> */}
</div> </div>
</div> </div>
); );

View file

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { __stub } from 'pl-fe/api'; import { __stub } from 'pl-fe/api';
import { ChatContext } from 'pl-fe/contexts/chat-context'; import { ChatContext } from 'pl-fe/contexts/chat-context';
@ -10,13 +9,11 @@ import { render, screen, waitFor } from 'pl-fe/jest/test-helpers';
import ChatPane from './chat-pane'; import ChatPane from './chat-pane';
const renderComponentWithChatContext = (store = {}) => render( const renderComponentWithChatContext = (store = {}) => render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<StatProvider> <StatProvider>
<ChatContext.Provider value={{ isOpen: true }}> <ChatContext.Provider value={{ isOpen: true }}>
<ChatPane /> <ChatPane />
</ChatContext.Provider> </ChatContext.Provider>
</StatProvider> </StatProvider>,
</VirtuosoMockContext.Provider>,
undefined, undefined,
store, store,
); );

View file

@ -1,6 +1,5 @@
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { __stub } from 'pl-fe/api'; import { __stub } from 'pl-fe/api';
import { ChatProvider } from 'pl-fe/contexts/chat-context'; import { ChatProvider } from 'pl-fe/contexts/chat-context';
@ -9,11 +8,9 @@ import { render, screen, waitFor } from 'pl-fe/jest/test-helpers';
import ChatSearch from './chat-search'; import ChatSearch from './chat-search';
const renderComponent = () => render( const renderComponent = () => render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 100 }}>
<ChatProvider> <ChatProvider>
<ChatSearch /> <ChatSearch />
</ChatProvider>, </ChatProvider>,
</VirtuosoMockContext.Provider>,
); );
describe('<ChatSearch />', () => { describe('<ChatSearch />', () => {

View file

@ -1,5 +1,5 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import React, { useState } from 'react'; import React, { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
@ -25,9 +25,9 @@ interface IChatSearch {
isMainPage?: boolean; isMainPage?: boolean;
} }
const ChatSearch = (props: IChatSearch) => { const ChatSearch: React.FC<IChatSearch> = ({ isMainPage = false }) => {
const parentRef = useRef<HTMLDivElement>(null);
const intl = useIntl(); const intl = useIntl();
const { isMainPage = false } = props;
const debounce = useDebounce; const debounce = useDebounce;
const history = useHistory(); const history = useHistory();
@ -70,6 +70,7 @@ const ChatSearch = (props: IChatSearch) => {
handleClickOnSearchResult.mutate(id); handleClickOnSearchResult.mutate(id);
clearValue(); clearValue();
}} }}
parentRef={parentRef}
/> />
); );
} else if (hasSearchValue && !hasSearchResults && !isFetching) { } else if (hasSearchValue && !hasSearchResults && !isFetching) {
@ -109,7 +110,7 @@ const ChatSearch = (props: IChatSearch) => {
/> />
</div> </div>
<Stack className='h-full grow overflow-auto'> <Stack className='h-full grow overflow-auto' ref={parentRef}>
{renderBody()} {renderBody()}
</Stack> </Stack>
</Stack> </Stack>

View file

@ -11,9 +11,10 @@ import type { Account } from 'pl-api';
interface IResults { interface IResults {
accountSearchResult: ReturnType<typeof useAccountSearch>; accountSearchResult: ReturnType<typeof useAccountSearch>;
onSelect(id: string): void; onSelect(id: string): void;
parentRef: React.RefObject<HTMLElement>;
} }
const Results = ({ accountSearchResult, onSelect }: IResults) => { const Results = ({ accountSearchResult, onSelect, parentRef }: IResults) => {
const { data: accounts, isFetching, hasNextPage, fetchNextPage } = accountSearchResult; const { data: accounts, isFetching, hasNextPage, fetchNextPage } = accountSearchResult;
const [isNearBottom, setNearBottom] = useState<boolean>(false); const [isNearBottom, setNearBottom] = useState<boolean>(false);
@ -47,8 +48,9 @@ const Results = ({ accountSearchResult, onSelect }: IResults) => {
</button> </button>
), []); ), []);
// <div className='relative grow'>
return ( return (
<div className='relative grow'> <>
<ScrollableList <ScrollableList
itemClassName='px-2' itemClassName='px-2'
loadMoreClassName='mx-4 mb-4' loadMoreClassName='mx-4 mb-4'
@ -59,25 +61,25 @@ const Results = ({ accountSearchResult, onSelect }: IResults) => {
isLoading={isFetching} isLoading={isFetching}
hasMore={hasNextPage} hasMore={hasNextPage}
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
useWindowScroll={false}
parentRef={parentRef}
> >
{(accounts || []).map((chat) => renderAccount(chat))} {(accounts || []).map((chat) => renderAccount(chat))}
</ScrollableList> </ScrollableList>
<>
<div <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', { className={clsx('pointer-events-none absolute inset-x-0 top-[58px] 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-0': isNearTop,
'opacity-100': !isNearTop, 'opacity-100': !isNearTop,
})} })}
/> />
<div <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', { 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-0': isNearBottom,
'opacity-100': !isNearBottom, 'opacity-100': !isNearBottom,
})} })}
/> />
</> </>
</div>
); );
}; };

View file

@ -161,7 +161,7 @@ const Chat: React.FC<ChatInterface> = ({ chat, inputRef, className }) => {
return ( return (
<Stack className={clsx('flex grow overflow-hidden', className)} onMouseOver={handleMouseOver}> <Stack className={clsx('flex grow overflow-hidden', className)} onMouseOver={handleMouseOver}>
<div className='flex h-full grow justify-center overflow-hidden'> <div className='flex h-full grow justify-center overflow-hidden'>
<ChatMessageList chat={chat} /> <ChatMessageList key={chat.id} chat={chat} />
</div> </div>
<ChatComposer <ChatComposer

View file

@ -1,8 +1,7 @@
import 'intersection-observer'; import 'intersection-observer';
import ResizeObserver from 'resize-observer-polyfill'; import ResizeObserver from 'resize-observer-polyfill';
// Needed by Virtuoso // Needed by @tanstack/virtual, I guess
// https://github.com/petyosi/react-virtuoso#browser-support
if (!window.ResizeObserver) { if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserver; window.ResizeObserver = ResizeObserver;
} }

View file

@ -27,10 +27,6 @@ const startSentry = async (dsn: string): Promise<void> => {
// localForage error in FireFox private browsing mode (which doesn't support IndexedDB). // localForage error in FireFox private browsing mode (which doesn't support IndexedDB).
// We only use IndexedDB as a cache, so we can safely ignore the error. // We only use IndexedDB as a cache, so we can safely ignore the error.
'No available storage method found', 'No available storage method found',
// Virtuoso throws these errors, but it is a false-positive.
// https://github.com/petyosi/react-virtuoso/issues/254
'ResizeObserver loop completed with undelivered notifications.',
'ResizeObserver loop limit exceeded',
], ],
denyUrls: [ denyUrls: [
// Browser extensions. // Browser extensions.

View file

@ -21,12 +21,6 @@ noscript {
@apply w-4 h-4 -mt-[0.2ex] mb-[0.2ex] inline-block align-middle object-contain; @apply w-4 h-4 -mt-[0.2ex] mb-[0.2ex] inline-block align-middle object-contain;
} }
// Virtuoso empty placeholder fix.
// https://gitlab.com/petyosi/soapbox-fe/-/commit/1e22c39934b60e5e186de804060ecfdf1955b506
div[data-viewport-type='window'] {
@apply static #{!important};
}
body.system-font, body.system-font,
body.system-font .font-sans { body.system-font .font-sans {
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;