Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 15 additions & 28 deletions apps/meteor/client/views/room/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,41 @@
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<typeof MessageListProvider>['messageListRef'];
scrollContainerRef?: MutableRefObject<HTMLElement | null>;
};

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<boolean>('displayAvatars');
const messageGroupingPeriod = useSetting('Message_GroupingPeriod', 300);
const maxRenderedMessages = useSetting('Message_VirtualList_MaxRendered', 50);
const firstUnreadMessageId = useFirstUnreadMessageId();

return (
<MessageListProvider messageListRef={messageListRef}>
<SelectedMessagesProvider>
{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 (
<Fragment key={message._id}>
<MessageListItem
message={message}
previous={previous}
showUnreadDivider={showUnreadDivider}
showUserAvatar={showUserAvatar}
sequential={sequential}
visible={visible}
subscription={subscription}
system={system}
/>
</Fragment>
);
})}
<VirtualizedMessageList
rid={rid}
messages={messages}
scrollContainerRef={scrollContainerRef}
messageGroupingPeriod={messageGroupingPeriod}
firstUnreadMessageId={firstUnreadMessageId}
showUserAvatar={showUserAvatar}
subscription={subscription}
maxRenderedMessages={maxRenderedMessages}
/>
</SelectedMessagesProvider>
</MessageListProvider>
);
Expand Down
129 changes: 129 additions & 0 deletions apps/meteor/client/views/room/MessageList/VirtualizedMessageList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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<HTMLElement | null>;
messageGroupingPeriod: number;
firstUnreadMessageId: string | undefined;
showUserAvatar: boolean;
subscription: ISubscription | undefined;
maxRenderedMessages?: number;
};

export function VirtualizedMessageList({
rid,
messages,
scrollContainerRef,
messageGroupingPeriod,
firstUnreadMessageId,
showUserAvatar,
subscription,
maxRenderedMessages = DEFAULT_MAX_RENDERED,
}: VirtualizedMessageListProps) {
const hasRestoredScrollRef = useRef(false);
// Use setting to limit overscan so we rarely render more than maxRenderedMessages; avoid slicing
// virtualItems so we never unmount rows (unmount clears measurements and causes scroll jump).
const maxRendered = Math.max(1, Math.floor(Number(maxRenderedMessages)));
const overscan = Math.min(OVERSCAN, Math.max(0, Math.floor(maxRendered / 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 (
<Box
is='li'
style={{
listStyle: 'none',
position: 'relative',
display: 'block',
width: '100%',
}}
>
<Box
style={{
height: `${totalSize}px`,
width: '100%',
position: 'relative',
}}
>
{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 (
<Box
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<MessageListItem
message={message}
previous={previous}
showUnreadDivider={showUnreadDivider}
showUserAvatar={showUserAvatar}
sequential={sequential}
visible={visible}
subscription={subscription}
system={system}
/>
</Box>
);
})}
</Box>
</Box>
);
}
12 changes: 10 additions & 2 deletions apps/meteor/client/views/room/body/RoomBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -126,6 +126,13 @@ const RoomBody = (): ReactElement => {
const { messageListRef } = useMessageListNavigation();
const { innerRef: selectAndScrollRef, selectAllAndScrollToTop } = useSelectAllAndScrollToTop();

const scrollContainerRef = useRef<HTMLElement | null>(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,
Expand All @@ -143,6 +150,7 @@ const RoomBody = (): ReactElement => {
selectAndScrollRef,
messageListRef,
jumpToRefGetMoreImperativeInnerRef,
scrollContainerRefCallback,
);

const handleNavigateToPreviousMessage = useCallback((): void => {
Expand Down Expand Up @@ -265,7 +273,7 @@ const RoomBody = (): ReactElement => {
)}
</>
) : null}
<MessageList rid={room._id} messageListRef={jumpToRef} />
<MessageList rid={room._id} messageListRef={jumpToRef} scrollContainerRef={scrollContainerRef} />
{hasMoreNextMessages ? (
<li className='load-more'>{isLoadingMoreMessages ? <LoadingMessagesIndicator /> : null}</li>
) : null}
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions apps/meteor/server/settings/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ export const createMessageSettings = () =>
public: true,
i18nDescription: 'Message_GroupingPeriodDescription',
});
await this.add('Message_VirtualList_MaxRendered', 50, {
type: 'int',
public: true,
i18nDescription: 'Message_VirtualList_MaxRendered_Description',
});
Comment on lines +139 to +143
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👎

await this.add('API_Embed', true, {
type: 'boolean',
public: true,
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -3433,6 +3433,8 @@
"Message_GlobalSearch": "Global Search",
"Message_GroupingPeriod": "Grouping Period (in seconds)",
"Message_GroupingPeriodDescription": "Messages will be grouped with previous message if both are from the same user and the elapsed time was less than the informed time in seconds.",
"Message_VirtualList_MaxRendered": "Message list maximum rendered messages",
"Message_VirtualList_MaxRendered_Description": "Maximum number of message DOM nodes to render at once in the virtualized message list. Lower values reduce performance impact when scrolled up or when many new messages are loaded; higher values may reduce blank areas while scrolling.",
"Message_HideType_added_user_to_team": "User added to team",
"Message_HideType_au": "User added",
"Message_HideType_ui": "User invited to room",
Expand Down
20 changes: 20 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading