diff --git a/apps/meteor/client/views/room/MessageList/MessageList.tsx b/apps/meteor/client/views/room/MessageList/MessageList.tsx index ea69e9c5fc91c..6bd4a0f0fbbb9 100644 --- a/apps/meteor/client/views/room/MessageList/MessageList.tsx +++ b/apps/meteor/client/views/room/MessageList/MessageList.tsx @@ -1,24 +1,21 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { isThreadMessage } from '@rocket.chat/core-typings'; -import { MessageTypes } from '@rocket.chat/message-types'; import { useSetting, useUserPreference } from '@rocket.chat/ui-contexts'; -import type { ComponentProps } from 'react'; -import { Fragment } from 'react'; +import type { ComponentProps, MutableRefObject } from 'react'; -import { MessageListItem } from './MessageListItem'; +import { VirtualizedMessageList } from './VirtualizedMessageList'; import { useRoomSubscription } from '../contexts/RoomContext'; import { useFirstUnreadMessageId } from '../hooks/useFirstUnreadMessageId'; import { SelectedMessagesProvider } from '../providers/SelectedMessagesProvider'; import { useMessages } from './hooks/useMessages'; -import { isMessageSequential } from './lib/isMessageSequential'; import MessageListProvider from './providers/MessageListProvider'; type MessageListProps = { rid: IRoom['_id']; messageListRef: ComponentProps['messageListRef']; + scrollContainerRef?: MutableRefObject; }; -export const MessageList = function MessageList({ rid, messageListRef }: MessageListProps) { +export const MessageList = function MessageList({ rid, messageListRef, scrollContainerRef }: MessageListProps) { const messages = useMessages({ rid }); const subscription = useRoomSubscription(); const showUserAvatar = !!useUserPreference('displayAvatars'); @@ -28,27 +25,15 @@ export const MessageList = function MessageList({ rid, messageListRef }: Message return ( - {messages.map((message, index, { [index - 1]: previous }) => { - const sequential = isMessageSequential(message, previous, messageGroupingPeriod); - const showUnreadDivider = firstUnreadMessageId === message._id; - const system = MessageTypes.isSystemMessage(message); - const visible = !isThreadMessage(message) && !system; - - return ( - - - - ); - })} + ); diff --git a/apps/meteor/client/views/room/MessageList/VirtualizedMessageList.tsx b/apps/meteor/client/views/room/MessageList/VirtualizedMessageList.tsx new file mode 100644 index 0000000000000..d7df8c50820cf --- /dev/null +++ b/apps/meteor/client/views/room/MessageList/VirtualizedMessageList.tsx @@ -0,0 +1,125 @@ +import type { IRoom, IMessage, ISubscription } from '@rocket.chat/core-typings'; +import { isThreadMessage } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import { MessageTypes } from '@rocket.chat/message-types'; +import { useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'; +import type { MutableRefObject } from 'react'; +import { useEffect, useRef } from 'react'; + +import { MessageListItem } from './MessageListItem'; +import { isMessageSequential } from './lib/isMessageSequential'; +import { RoomManager } from '../../../lib/RoomManager'; + +const ESTIMATE_SIZE = 84; +const OVERSCAN = 5; +const DEFAULT_MAX_RENDERED = 50; + +type VirtualizedMessageListProps = { + rid: IRoom['_id']; + messages: IMessage[]; + scrollContainerRef?: MutableRefObject; + messageGroupingPeriod: number; + firstUnreadMessageId: string | undefined; + showUserAvatar: boolean; + subscription: ISubscription | undefined; +}; + +export function VirtualizedMessageList({ + rid, + messages, + scrollContainerRef, + messageGroupingPeriod, + firstUnreadMessageId, + showUserAvatar, + subscription, +}: VirtualizedMessageListProps) { + const hasRestoredScrollRef = useRef(false); + // Limit overscan to cap how many rows render; avoid slicing virtualItems (causes scroll jump on unmount). + const overscan = Math.min(OVERSCAN, Math.max(0, Math.floor(DEFAULT_MAX_RENDERED / 2) - 2)); + + const virtualizer = useVirtualizer({ + count: messages.length, + getScrollElement: () => scrollContainerRef?.current ?? null, + estimateSize: () => ESTIMATE_SIZE, + overscan, + getItemKey: (index: number) => messages[index]?._id ?? index, + }); + + const virtualItems = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + + // Restore scroll position when returning to a channel (after virtual list has laid out). + useEffect(() => { + if (hasRestoredScrollRef.current || totalSize <= 0) { + return; + } + const store = RoomManager.getStore(rid); + if (store?.scroll == null || store.atBottom) { + return; + } + hasRestoredScrollRef.current = true; + virtualizer.scrollToOffset(store.scroll, { align: 'start' }); + }, [rid, totalSize, virtualizer]); + + if (messages.length === 0) { + return null; + } + + return ( + + + {virtualItems.map((virtualRow: VirtualItem) => { + const message = messages[virtualRow.index]; + if (!message) { + return null; + } + const previous = messages[virtualRow.index - 1]; + const sequential = isMessageSequential(message, previous, messageGroupingPeriod); + const showUnreadDivider = firstUnreadMessageId === message._id; + const system = MessageTypes.isSystemMessage(message); + const visible = !isThreadMessage(message) && !system; + + return ( + + + + ); + })} + + + ); +} diff --git a/apps/meteor/client/views/room/body/RoomBody.tsx b/apps/meteor/client/views/room/body/RoomBody.tsx index cff19dec9641d..cf4e875a87d7a 100644 --- a/apps/meteor/client/views/room/body/RoomBody.tsx +++ b/apps/meteor/client/views/room/body/RoomBody.tsx @@ -3,7 +3,7 @@ import { isTruthy } from '@rocket.chat/tools'; import { CustomScrollbars, useEmbeddedLayout } from '@rocket.chat/ui-client'; import { usePermission, useRole, useSetting, useTranslation, useUser, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts'; import type { MouseEvent, ReactElement } from 'react'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback, useMemo, useRef, useState } from 'react'; import { useMergedRefsV2 } from '../../../hooks/useMergedRefsV2'; import { BubbleDate } from '../BubbleDate'; @@ -126,6 +126,13 @@ const RoomBody = (): ReactElement => { const { messageListRef } = useMessageListNavigation(); const { innerRef: selectAndScrollRef, selectAllAndScrollToTop } = useSelectAllAndScrollToTop(); + const scrollContainerRef = useRef(null); + const [, setScrollContainerReady] = useState(false); + const scrollContainerRefCallback = useCallback((el: HTMLElement | null) => { + scrollContainerRef.current = el; + setScrollContainerReady((prev) => (el ? true : prev)); + }, []); + const { handleNewMessageButtonClick, handleJumpToRecentButtonClick, handleComposerResize, hasNewMessages, newMessagesScrollRef } = useHasNewMessages(room._id, user?._id, atBottomRef, { sendToBottom, @@ -143,6 +150,7 @@ const RoomBody = (): ReactElement => { selectAndScrollRef, messageListRef, jumpToRefGetMoreImperativeInnerRef, + scrollContainerRefCallback, ); const handleNavigateToPreviousMessage = useCallback((): void => { @@ -265,7 +273,7 @@ const RoomBody = (): ReactElement => { )} ) : null} - + {hasMoreNextMessages ? (
  • {isLoadingMoreMessages ? : null}
  • ) : null} diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 2ea7d85d76bec..e132c00cbe21c 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -161,6 +161,7 @@ "@slack/bolt": "^3.22.0", "@slack/rtm-api": "~7.0.4", "@tanstack/react-query": "~5.65.1", + "@tanstack/react-virtual": "^3.13.2", "@types/meteor": "^2.9.10", "@xmldom/xmldom": "~0.8.11", "adm-zip": "0.5.16", diff --git a/yarn.lock b/yarn.lock index ef1cc5b3ab342..03f1048e82f38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9864,6 +9864,7 @@ __metadata: "@storybook/react": "npm:^8.6.17" "@storybook/react-webpack5": "npm:^8.6.17" "@tanstack/react-query": "npm:~5.65.1" + "@tanstack/react-virtual": "npm:^3.13.2" "@testing-library/dom": "npm:~10.4.1" "@testing-library/react": "npm:~16.3.2" "@testing-library/user-event": "npm:~14.6.1" @@ -13334,6 +13335,25 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-virtual@npm:^3.13.2": + version: 3.13.21 + resolution: "@tanstack/react-virtual@npm:3.13.21" + dependencies: + "@tanstack/virtual-core": "npm:3.13.21" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/c4dd558ab6a260fa959d35b736a9b42d32927ff90995a00bb4f8382cb28f84cbe9b2ea387e5631166df4b1f862ed13bd68c11208931fc9c73ee7950f9e5830ea + languageName: node + linkType: hard + +"@tanstack/virtual-core@npm:3.13.21": + version: 3.13.21 + resolution: "@tanstack/virtual-core@npm:3.13.21" + checksum: 10/efd45e986dcfbd4aaa33537e255ccb3fc847b1a5eba1d7e73cd397f354b4622ecb824acb94f173dc3c8607e3094aa3a5b153e8a94d0839d0fa106f4f741d0c1f + languageName: node + linkType: hard + "@testing-library/dom@npm:10.4.0": version: 10.4.0 resolution: "@testing-library/dom@npm:10.4.0"