From 4447a3cda419d2ae3748ce76ca928b187f1024e4 Mon Sep 17 00:00:00 2001 From: Justin Date: Tue, 4 Oct 2022 10:48:37 -0400 Subject: [PATCH] Convert ChatMessageList to Virtuoso --- .../chats/components/chat-message-list.tsx | 242 +++++++----------- app/soapbox/queries/chats.ts | 3 +- 2 files changed, 89 insertions(+), 156 deletions(-) diff --git a/app/soapbox/features/chats/components/chat-message-list.tsx b/app/soapbox/features/chats/components/chat-message-list.tsx index f7a2597fe..674947c45 100644 --- a/app/soapbox/features/chats/components/chat-message-list.tsx +++ b/app/soapbox/features/chats/components/chat-message-list.tsx @@ -2,9 +2,9 @@ import { useMutation } from '@tanstack/react-query'; import classNames from 'clsx'; import { List as ImmutableList } from 'immutable'; import escape from 'lodash/escape'; -import throttle from 'lodash/throttle'; -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useIntl, defineMessages } from 'react-intl'; +import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { openModal } from 'soapbox/actions/modals'; import { initReport } from 'soapbox/actions/reports'; @@ -66,47 +66,74 @@ interface IChatMessageList { autosize?: boolean, } +const START_INDEX = 10000; + /** Scrollable list of chat messages. */ const ChatMessageList: React.FC = ({ chat, autosize }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const account = useOwnAccount(); - const [initialLoad, setInitialLoad] = useState(true); - const [scrollPosition, setScrollPosition] = useState(0); + const node = useRef(null); + const [firstItemIndex, setFirstItemIndex] = useState(START_INDEX - 20); const { deleteChatMessage, markChatAsRead } = useChatActions(chat.id); const { data: chatMessages, fetchNextPage, + hasNextPage, isError, - isFetched, isFetching, isFetchingNextPage, isLoading, - isPlaceholderData, - hasNextPage, refetch, } = useChatMessages(chat.id); + const formattedChatMessages = chatMessages || []; const me = useAppSelector((state) => state.me); const isBlocked = useAppSelector((state) => state.getIn(['relationships', chat.account.id, 'blocked_by'])); - const node = useRef(null); - const messagesEnd = useRef(null); - const lastComputedScroll = useRef(undefined); - const scrollBottom = useRef(undefined); - const handleDeleteMessage = useMutation((chatMessageId: string) => deleteChatMessage(chatMessageId), { onSettled: () => { queryClient.invalidateQueries(chatKeys.chatMessages(chat.id)); }, }); - const scrollToBottom = () => { - messagesEnd.current?.scrollIntoView(false); - }; + const lastChatMessage = chatMessages ? chatMessages[chatMessages.length - 1] : null; + + const cachedChatMessages = useMemo(() => { + if (!chatMessages) { + return []; + } + + const nextFirstItemIndex = START_INDEX - chatMessages.length; + setFirstItemIndex(nextFirstItemIndex); + return chatMessages.reduce((acc: any, curr: any, idx: number) => { + const lastMessage = formattedChatMessages[idx - 1]; + + if (lastMessage) { + switch (timeChange(lastMessage, curr)) { + case 'today': + acc.push({ + type: 'divider', + text: intl.formatMessage(messages.today), + }); + break; + case 'date': + acc.push({ + type: 'divider', + text: intl.formatDate(new Date(curr.created_at), { weekday: 'short', hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' }), + }); + break; + } + } + + acc.push(curr); + return acc; + }, []); + + }, [chatMessages?.length, lastChatMessage]); const getFormattedTimestamp = (chatMessage: ChatMessageEntity) => { return intl.formatDate(new Date(chatMessage.created_at), { @@ -136,52 +163,12 @@ const ChatMessageList: React.FC = ({ chat, autosize }) => { } }; - const isNearBottom = (): boolean => { - const elem = node.current; - if (!elem) return false; - - const scrollBottom = elem.scrollHeight - elem.offsetHeight - elem.scrollTop; - return scrollBottom < elem.offsetHeight * 1.5; - }; - - const restoreScrollPosition = () => { - if (node.current && scrollBottom.current) { - lastComputedScroll.current = node.current.scrollHeight - scrollBottom.current; - node.current.scrollTop = lastComputedScroll.current; + const handleStartReached = useCallback(() => { + if (hasNextPage && !isFetching) { + fetchNextPage(); } - }; - - const handleLoadMore = () => { - // const maxId = chatMessages.getIn([0, 'id']) as string; - // dispatch(fetchChatMessages(chat.id, maxId as any)); - // setIsLoading(true); - if (!isFetching && hasNextPage) { - // setMaxId(formattedChatMessages[0].id); - fetchNextPage() - .then(() => { - if (node.current) { - setScrollPosition(node.current.scrollHeight - node.current.scrollTop); - } - }) - .catch(() => null); - } - }; - - const handleScroll = throttle(() => { - if (node.current) { - const { scrollTop, offsetHeight } = node.current; - const computedScroll = lastComputedScroll.current === scrollTop; - const nearTop = scrollTop < offsetHeight; - - setScrollPosition(node.current.scrollHeight - node.current.scrollTop); - - if (nearTop && !isFetching && !initialLoad && !computedScroll) { - handleLoadMore(); - } - } - }, 150, { - trailing: true, - }); + return false; + }, [firstItemIndex, hasNextPage, isFetching]); const onOpenMedia = (media: any, index: number) => { dispatch(openModal('MEDIA', { media, index })); @@ -316,13 +303,6 @@ const ChatMessageList: React.FC = ({ chat, autosize }) => { > {maybeRenderMedia(chatMessage)} -
- -
@@ -358,37 +338,6 @@ const ChatMessageList: React.FC = ({ chat, autosize }) => { ); }; - useEffect(() => { - if (isFetched) { - setInitialLoad(false); - scrollToBottom(); - } - }, [isFetched]); - - // Store the scroll position. - // useLayoutEffect(() => { - // if (node.current) { - // const { scrollHeight, scrollTop } = node.current; - // scrollBottom.current = scrollHeight - scrollTop; - // } - // }); - - // Stick scrollbar to bottom. - useEffect(() => { - if (isNearBottom()) { - setTimeout(() => { - scrollToBottom(); - }, 25); - } - - // First load. - // if (chatMessages.count() !== initialCount) { - // setInitialLoad(false); - // setIsLoading(false); - // scrollToBottom(); - // } - }, [formattedChatMessages.length]); - useEffect(() => { const lastMessage = formattedChatMessages.pop(); const lastMessageId = lastMessage?.id; @@ -398,22 +347,6 @@ const ChatMessageList: React.FC = ({ chat, autosize }) => { } }, [formattedChatMessages.length]); - useEffect(() => { - // Restore scroll bar position when loading old messages. - if (!initialLoad) { - restoreScrollPosition(); - } - }, [formattedChatMessages.length, initialLoad]); - - - if (isPlaceholderData) { - return ( - - - - ); - } - if (isBlocked) { return ( @@ -455,49 +388,50 @@ const ChatMessageList: React.FC = ({ chat, autosize }) => { } return ( -
{/* style={{ height: autosize ? 'calc(100vh - 16rem)' : undefined }} */} - {!isLoading ? ( - - ) : null} - - {isFetchingNextPage ? ( -
- -
- ) : null} - -
- {isLoading ? ( - <> - - - - - - - ) : ( - formattedChatMessages.reduce((acc: any, curr: any, idx: number) => { - const lastMessage = formattedChatMessages[idx - 1]; - - if (lastMessage) { - const key = `${curr.id}_divider`; - switch (timeChange(lastMessage, curr)) { - case 'today': - acc.push(renderDivider(key, intl.formatMessage(messages.today))); - break; - case 'date': - acc.push(renderDivider(key, intl.formatDate(new Date(curr.created_at), { weekday: 'short', hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' }))); - break; - } +
+
+ { + if (chatMessage.type === 'divider') { + return renderDivider(_index, chatMessage.text); + } else { + return ( +
+ {renderMessage(chatMessage)} +
+ ); } + }} + followOutput='auto' + components={{ + Header: () => { + if (hasNextPage && isFetchingNextPage) { + return
; + } - acc.push(renderMessage(curr)); - return acc; - }, [] as React.ReactNode[]) - )} + if (!hasNextPage && !isLoading) { + return
; + } + + return null; + }, + EmptyPlaceholder: () => ( +
+ + + + + +
+ ), + }} + />
- -
); }; diff --git a/app/soapbox/queries/chats.ts b/app/soapbox/queries/chats.ts index 6cbaa06dd..9424fa494 100644 --- a/app/soapbox/queries/chats.ts +++ b/app/soapbox/queries/chats.ts @@ -10,7 +10,6 @@ import compareId from 'soapbox/compare_id'; import { useChatContext } from 'soapbox/contexts/chat-context'; import { useStatContext } from 'soapbox/contexts/stat-context'; import { useApi, useAppDispatch, useFeatures } from 'soapbox/hooks'; -import { normalizeChatMessage } from 'soapbox/normalizers'; import { flattenPages, PaginatedResult, updatePageItem } from 'soapbox/utils/queries'; import { queryClient } from './client'; @@ -73,7 +72,7 @@ const useChatMessages = (chatId: string) => { const link = getNextLink(response); const hasMore = !!link; - const result = data.sort(reverseOrder).map(normalizeChatMessage); + const result = data.sort(reverseOrder); return { result,