pl-fe: Migrate ChatMessageList to @tanstack/virtual
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
58a7122552
commit
4965cee37b
11 changed files with 94 additions and 91 deletions
|
@ -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();
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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 />', () => {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue