Skip to content
Open
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
85 changes: 85 additions & 0 deletions apps/meteor/client/components/VirtualList/VirtualList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { CustomScrollbars } from '@rocket.chat/ui-client';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { ReactNode } from 'react';
import { useState, useEffect, useRef } from 'react';

type VirtualListProps<T> = {
items: T[];
totalCount: number;
renderItem: (item: T, index: number) => ReactNode;
estimateSize?: (index: number) => number;
overscan?: number;
gap?: number;
paddingStart?: number;
paddingEnd?: number;
onEndReached?: () => void;
};

const VirtualList = <T,>({
items,
totalCount,
renderItem,
estimateSize = () => 120,
overscan = 5,
gap,
paddingStart,
paddingEnd,
onEndReached,
}: VirtualListProps<T>) => {
const [scrollElement, setScrollElement] = useState<HTMLElement | null>(null);

const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollElement,
estimateSize,
overscan,
gap,
paddingStart,
paddingEnd,
});

const onEndReachedRef = useRef(onEndReached);
useEffect(() => {
onEndReachedRef.current = onEndReached;
});

const virtualItems = virtualizer.getVirtualItems();
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;

useEffect(() => {
if (lastVirtualItemIndex !== undefined && lastVirtualItemIndex >= items.length - 1 && items.length < totalCount) {
onEndReachedRef.current?.();
}
}, [lastVirtualItemIndex, items.length, totalCount]);

return (
<CustomScrollbars ref={setScrollElement}>
<div
style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderItem(items[virtualRow.index], virtualRow.index)}
</div>
))}
</div>
</CustomScrollbars>
);
};

export default VirtualList;
1 change: 1 addition & 0 deletions apps/meteor/client/components/VirtualList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as VirtualList } from './VirtualList';
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { IDiscussionMessage } from '@rocket.chat/core-typings';
import { render, screen } from '@testing-library/react';
import { forwardRef } from 'react';

import DiscussionsList from './DiscussionsList';

// DiscussionsListRow brings in its own hooks (useTimeAgo, normalizeThreadMessage, etc.).
// Mock it to a simple sentinel so tests stay focused on DiscussionsList behaviour.
jest.mock('./DiscussionsListRow', () => () => <div data-testid='discussion-row' />);

// goToRoomById traverses a deep import chain that includes non-JS assets.
jest.mock('../../../../lib/utils/goToRoomById', () => ({ goToRoomById: jest.fn() }));

// JSDOM has no layout engine, so useVirtualizer can't calculate visible ranges.
// Replace it with a deterministic implementation that always renders all items.
jest.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count, estimateSize }: { count: number; estimateSize: (i: number) => number }) => ({
getVirtualItems: () =>
Array.from({ length: count }, (_, i) => ({
key: i,
index: i,
start: i * estimateSize(i),
end: (i + 1) * estimateSize(i),
size: estimateSize(i),
lane: 0,
})),
getTotalSize: () => count * estimateSize(0),
measureElement: () => undefined,
}),
}));

// CustomScrollbars uses OverlayScrollbars which is unavailable in JSDOM.
// Replace with a forwardRef div so the VirtualList scroll-element ref is wired correctly.
jest.mock('@rocket.chat/ui-client', () => ({
...jest.requireActual('@rocket.chat/ui-client'),
CustomScrollbars: forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(function CustomScrollbars({ children, ...props }, ref) {
return (
<div ref={ref} {...props}>
{children}
</div>
);
}),
}));

jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

jest.mock('@rocket.chat/ui-contexts', () => ({
useSetting: () => false,
useLayoutSizes: () => ({ contextualBar: 'wide' }),
useLayoutContextualBarPosition: () => 'side',
useRoomToolbox: () => ({ closeTab: jest.fn() }),
}));

const defaultProps = {
total: 0,
discussions: [] as IDiscussionMessage[],
loadMoreItems: jest.fn(),
loading: false,
onClose: jest.fn(),
error: undefined as unknown,
text: '',
onChangeFilter: jest.fn(),
};

const createFakeDiscussion = (id: string): IDiscussionMessage =>
({
_id: id,
rid: 'room-id',
msg: `Discussion message ${id}`,
ts: new Date(),
u: { _id: 'user-id', username: 'testuser', name: 'Test User' },
_updatedAt: new Date(),
drid: `drid-${id}`,
dcount: 2,
dlm: new Date(),
}) as IDiscussionMessage;

describe('DiscussionsList', () => {
it('should hide the empty state while loading', () => {
render(<DiscussionsList {...defaultProps} loading={true} total={0} />);

expect(screen.queryByText('No_Discussions_found')).not.toBeInTheDocument();
});

it('should display an error message when error is an Error instance', () => {
render(<DiscussionsList {...defaultProps} error={new Error('Something went wrong')} />);

expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
});

it('should display the empty state when there are no discussions and loading is done', () => {
render(<DiscussionsList {...defaultProps} loading={false} total={0} />);

expect(screen.getByText('No_Discussions_found')).toBeInTheDocument();
});

it('should render a row for each discussion item', () => {
const discussions = [createFakeDiscussion('1'), createFakeDiscussion('2'), createFakeDiscussion('3')];

render(<DiscussionsList {...defaultProps} total={discussions.length} discussions={discussions} />);

expect(screen.getAllByTestId('discussion-row')).toHaveLength(discussions.length);
});
});
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { IDiscussionMessage } from '@rocket.chat/core-typings';
import { Box, Icon, TextInput, Callout, Throbber } from '@rocket.chat/fuselage';
import { useResizeObserver, useAutoFocus } from '@rocket.chat/fuselage-hooks';
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
import {
VirtualizedScrollbars,
ContextualbarHeader,
ContextualbarIcon,
ContextualbarContent,
Expand All @@ -16,15 +15,15 @@ import { useSetting } from '@rocket.chat/ui-contexts';
import type { ChangeEvent, MouseEvent, RefObject } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';

import DiscussionsListRow from './DiscussionsListRow';
import { VirtualList } from '../../../../components/VirtualList';
import { goToRoomById } from '../../../../lib/utils/goToRoomById';

type DiscussionsListProps = {
total: number;
discussions: Array<IDiscussionMessage>;
loadMoreItems: (start: number, end: number) => void;
loadMoreItems: () => void;
loading: boolean;
onClose: () => void;
error: unknown;
Expand All @@ -51,10 +50,6 @@ function DiscussionsList({
if (drid) goToRoomById(drid);
}, []);

const { ref, contentBoxSize: { inlineSize = 378, blockSize = 1 } = {} } = useResizeObserver<HTMLElement>({
debounceDelay: 200,
});

return (
<ContextualbarDialog>
<ContextualbarHeader>
Expand All @@ -71,7 +66,7 @@ function DiscussionsList({
addon={<Icon name='magnifier' size='x20' />}
/>
</ContextualbarSection>
<ContextualbarContent paddingInline={0} ref={ref}>
<ContextualbarContent paddingInline={0}>
{loading && (
<Box pi={24} pb={12}>
<Throbber size='x12' />
Expand All @@ -88,19 +83,15 @@ function DiscussionsList({

<Box flexGrow={1} flexShrink={1} overflow='hidden' display='flex'>
{!error && total > 0 && discussions.length > 0 && (
<VirtualizedScrollbars>
<Virtuoso
style={{
height: blockSize,
width: inlineSize,
}}
totalCount={total}
endReached={loading ? () => undefined : (start) => loadMoreItems(start, Math.min(50, total - start))}
overscan={25}
data={discussions}
itemContent={(_, data) => <DiscussionsListRow discussion={data} showRealNames={showRealNames} onClick={onClick} />}
/>
</VirtualizedScrollbars>
<VirtualList
items={discussions}
totalCount={total}
estimateSize={() => 120}
onEndReached={loading ? undefined : loadMoreItems}
renderItem={(discussion) => (
<DiscussionsListRow discussion={discussion} showRealNames={showRealNames} onClick={onClick} />
)}
/>
)}
</Box>
</ContextualbarContent>
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
"@slack/bolt": "^3.22.0",
"@slack/rtm-api": "~7.0.4",
"@tanstack/react-query": "~5.65.1",
"@tanstack/react-virtual": "~3.13.19",
"@types/meteor": "^2.9.10",
"@xmldom/xmldom": "~0.8.11",
"adm-zip": "0.5.16",
Expand Down
20 changes: 20 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9209,6 +9209,7 @@ __metadata:
"@storybook/react": "npm:^8.6.14"
"@storybook/react-webpack5": "npm:^8.6.14"
"@tanstack/react-query": "npm:~5.65.1"
"@tanstack/react-virtual": "npm:~3.13.19"
"@testing-library/dom": "npm:~10.4.1"
"@testing-library/react": "npm:~16.3.0"
"@testing-library/user-event": "npm:~14.6.1"
Expand Down Expand Up @@ -12474,6 +12475,25 @@ __metadata:
languageName: node
linkType: hard

"@tanstack/react-virtual@npm:~3.13.19":
version: 3.13.19
resolution: "@tanstack/react-virtual@npm:3.13.19"
dependencies:
"@tanstack/virtual-core": "npm:3.13.19"
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/6c825b0a9134951b9f51fc22a8793a0eb8eb9f6171e2cdec00aa6bf8f0980f53771eefa255334457123ed6a2fe271239140b572f9df9c5ec29e3ce034906f48e
languageName: node
linkType: hard

"@tanstack/virtual-core@npm:3.13.19":
version: 3.13.19
resolution: "@tanstack/virtual-core@npm:3.13.19"
checksum: 10/9b55ccacba1c15ce7226fafc26600e4dbffebfacc609c103fa226ec67c13e124451f99dcb84581678471729064524f87f62d88d7d922a4f8de5d7a6240d8e320
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