snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
import { composeStories } from '@storybook/react';
import { render, screen, within } from '@testing-library/react';
import { axe } from 'jest-axe';
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import * as React from 'react';
import { Children, forwardRef, isValidElement } from 'react';
import * as stories from './DiscussionsList.stories';
jest.mock('../../../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: {
getRoomDirectives: jest.fn(() => ({})),
},
}));
const mockVirtualizerHandle = {
scrollToIndex: jest.fn(),
scrollTo: jest.fn(),
findItemIndex: jest.fn((offset: number) => offset),
scrollOffset: 0,
scrollSize: 1000,
viewportSize: 300,
};
type MockVListProps = {
children: ReactNode;
onScroll?: (offset: number) => void;
as?: React.ElementType;
item?: React.ElementType;
shift?: boolean;
style?: CSSProperties;
className?: string;
};
jest.mock('virtua', () => {
return {
Virtualizer: React.forwardRef(
(
{ children, onScroll, as: asRoot = 'div', item: asItem = 'div', shift: _shift, style, className }: MockVListProps,
ref: React.Ref<unknown>,
) => {
React.useImperativeHandle(ref, () => mockVirtualizerHandle);
const Root = asRoot;
const Item = asItem;
const wrapped = Children.map(children, (child, index) => {
const key = isValidElement(child) && child.key != null ? String(child.key) : `row-${index}`;
return <Item key={key}>{child}</Item>;
});
return (
<Root
className={className}
data-testid='discussions-virtual-list'
style={style ?? { height: '100%' }}
onScroll={() => onScroll?.(mockVirtualizerHandle.scrollOffset)}
>
{wrapped}
</Root>
);
},
),
};
});
jest.mock('@rocket.chat/ui-client', () => ({
...jest.requireActual('@rocket.chat/ui-client'),
CustomVirtuaScrollbars: forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function CustomVirtuaScrollbars(
{ children, ...props },
ref,
) {
// eslint-disable-next-line testing-library/no-node-access
const content = isValidElement<{ children?: ReactNode }>(children) && children.type === 'div' ? children.props.children : children;
return (
<div ref={ref} {...props}>
{content}
</div>
);
}),
}));
const composed = composeStories(stories);
const testCases = Object.values(composed).map((Story) => [Story.storyName || 'Story', Story] as const);
describe('DiscussionsList', () => {
it('renders Default with virtual list and discussion rows', () => {
const { Default } = composed;
render(<Default />);
const list = screen.getByTestId('discussions-virtual-list');
expect(list).toBeInTheDocument();
expect(list.tagName.toLowerCase()).toBe('ul');
expect(within(list).getAllByRole('listitem')).toHaveLength(10);
expect(within(list).getAllByText('user.name')).toHaveLength(10);
});
it('renders Empty without virtual list', () => {
const { Empty } = composed;
render(<Empty />);
expect(screen.queryByTestId('discussions-virtual-list')).not.toBeInTheDocument();
expect(screen.getByText('No_Discussions_found')).toBeInTheDocument();
});
it('renders Loading without virtual list', () => {
const { Loading } = composed;
render(<Loading />);
expect(screen.queryByTestId('discussions-virtual-list')).not.toBeInTheDocument();
});
});
test.each(testCases)('renders %s with stable structure', (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(baseElement).toMatchSnapshot();
});
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

View File

@@ -0,0 +1,63 @@
import { Contextualbar } from '@rocket.chat/ui-client';
import type { Meta } from '@storybook/react';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import { action } from 'storybook/actions';
import DiscussionsList from './DiscussionsList';
const loadMoreItemsAction = action('loadMoreItems');
const loadMoreItems = (async (options) => {
loadMoreItemsAction(options);
return {} as Awaited<ReturnType<UseInfiniteQueryResult['fetchNextPage']>>;
}) satisfies UseInfiniteQueryResult['fetchNextPage'];
export default {
component: DiscussionsList,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
args: {
text: '',
loadMoreItems,
},
} satisfies Meta<typeof DiscussionsList>;
const fakeDiscussions = Array.from({ length: 10 }, (_, i) => ({
_id: String(i),
msg: `Discussion ${i}`,
ts: new Date('2024-01-01T00:00:00Z'),
username: 'user.name',
dcount: 5,
dlm: new Date('2024-01-01T00:00:00Z'),
drid: `drid-${i}`,
rid: 'roomId',
_updatedAt: new Date('2024-01-01T00:00:00Z'),
u: {
_id: 'user-id',
username: 'user.name',
},
}));
export const Default = {
args: {
isSuccess: true,
discussions: fakeDiscussions,
itemCount: fakeDiscussions.length,
},
};
export const Loading = {
args: {
isPending: true,
},
};
export const Empty = {
args: {
isSuccess: true,
discussions: [],
itemCount: 0,
},
};

View File

@@ -0,0 +1,117 @@
import type { IDiscussionMessage } from '@rocket.chat/core-typings';
import { Box, Icon, TextInput, Callout, Throbber } from '@rocket.chat/fuselage';
import { useAutoFocus } from '@rocket.chat/fuselage-hooks';
import {
ContextualbarHeader,
ContextualbarIcon,
ContextualbarContent,
ContextualbarClose,
ContextualbarEmptyContent,
ContextualbarTitle,
ContextualbarSection,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import type { ChangeEvent, MouseEvent, RefObject } from 'react';
import { useCallback, useId } from 'react';
import { useTranslation } from 'react-i18next';
import DiscussionsListRow from './DiscussionsListRow';
import { PaginatedVirtualList } from '../../../../components/PaginatedVirtualList';
import ResultsLiveRegion from '../../../../components/ResultsLiveRegion';
import { useGoToRoom } from '../../hooks/useGoToRoom';
export type DiscussionsListProps = {
itemCount: number;
discussions: Array<IDiscussionMessage>;
loadMoreItems: UseInfiniteQueryResult['fetchNextPage'];
isPending: boolean;
isSuccess: boolean;
onClose: () => void;
error: unknown;
text: string;
onChangeFilter: (e: ChangeEvent<HTMLInputElement>) => void;
};
function DiscussionsList({
itemCount,
discussions = [],
loadMoreItems,
isPending,
isSuccess,
onClose,
error,
text,
onChangeFilter,
}: DiscussionsListProps) {
const { t } = useTranslation();
const discussionListId = useId();
const showRealNames = useSetting('UI_Use_Real_Name', false);
const inputRef = useAutoFocus(true);
const goToRoom = useGoToRoom();
const onClick = useCallback(
(e: MouseEvent<HTMLElement>) => {
const { drid } = e.currentTarget.dataset;
if (drid) void goToRoom(drid);
},
[goToRoom],
);
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarIcon name='discussion' />
<ContextualbarTitle>{t('Discussions')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<ContextualbarSection>
<TextInput
placeholder={t('Search_Messages')}
aria-label={t('Search_Messages')}
aria-controls={isSuccess ? discussionListId : undefined}
value={text}
onChange={onChangeFilter}
ref={inputRef as RefObject<HTMLInputElement>}
endAddon={<Icon name='magnifier' size='x20' />}
/>
</ContextualbarSection>
<ContextualbarContent paddingInline={0}>
<ResultsLiveRegion shouldAnnounce={isSuccess} itemCount={itemCount} />
{isPending && (
<Box paddingInline={24} paddingBlock={12}>
<Throbber size='x12' />
</Box>
)}
{error instanceof Error && (
<Callout marginInline={24} type='danger'>
{error.toString()}
</Callout>
)}
{isSuccess && (
<Box id={discussionListId} width='full' height='full' overflow='hidden' flexShrink={1}>
{discussions.length === 0 && <ContextualbarEmptyContent title={t('No_Discussions_found')} />}
{discussions.length > 0 && (
<Box height='full' width='full' style={{ minHeight: 0 }}>
<PaginatedVirtualList
items={discussions}
totalCount={itemCount}
overscan={25}
onEndReached={isPending ? undefined : loadMoreItems}
renderItem={(discussion) => (
<DiscussionsListRow discussion={discussion} showRealNames={showRealNames} onClick={onClick} />
)}
/>
</Box>
)}
</Box>
)}
</ContextualbarContent>
</ContextualbarDialog>
);
}
export default DiscussionsList;

View File

@@ -0,0 +1,54 @@
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { useUserId, useRoomToolbox } from '@rocket.chat/ui-contexts';
import type { ChangeEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import DiscussionsList from './DiscussionsList';
import { useDiscussionsList } from './useDiscussionsList';
import { useRoom } from '../../contexts/RoomContext';
const DiscussionListContextBar = () => {
const userId = useUserId();
const room = useRoom();
const { closeTab } = useRoomToolbox();
const [text, setText] = useState('');
const debouncedText = useDebouncedValue(text, 400);
const options = useMemo(
() => ({
rid: room._id,
text: debouncedText,
}),
[room._id, debouncedText],
);
const { isPending, isSuccess, error, data, fetchNextPage } = useDiscussionsList(options);
const discussions = data?.items || [];
const itemCount = data?.itemCount ?? 0;
const handleTextChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setText(e.currentTarget.value);
}, []);
if (!userId) {
return null;
}
return (
<DiscussionsList
onClose={closeTab}
error={error}
discussions={discussions}
itemCount={itemCount}
isPending={isPending}
isSuccess={isSuccess}
loadMoreItems={fetchNextPage}
text={text}
onChangeFilter={handleTextChange}
/>
);
};
export default DiscussionListContextBar;

View File

@@ -0,0 +1,41 @@
import type { IDiscussionMessage } from '@rocket.chat/core-typings';
import type { MouseEvent } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import DiscussionsListItem from './components/DiscussionsListItem';
import { useTimeAgo } from '../../../../hooks/useTimeAgo';
import { normalizeThreadMessage } from '../../../../lib/normalizeThreadMessage';
export type DiscussionListRowProps = {
discussion: IDiscussionMessage;
showRealNames: boolean;
onClick: (e: MouseEvent<HTMLElement>) => void;
};
function DiscussionListRow({ discussion, showRealNames, onClick }: DiscussionListRowProps) {
const { t } = useTranslation();
const formatDate = useTimeAgo();
const msg = normalizeThreadMessage(discussion, t);
const { name = discussion.u.username } = discussion.u;
return (
<DiscussionsListItem
_id={discussion._id}
emoji={discussion.emoji}
dcount={discussion.dcount}
dlm={discussion.dlm}
name={showRealNames ? name : discussion.u.username}
username={discussion.u.username}
data-drid={discussion.drid}
ts={discussion.ts}
msg={msg}
formatDate={formatDate}
onClick={onClick}
/>
);
}
export default memo(DiscussionListRow);

View File

@@ -0,0 +1,30 @@
import DiscussionsListItem from './DiscussionsListItem';
const message = {
msg: 'hello world',
ts: new Date(0),
username: 'guilherme.gazzo',
dcount: 5,
dlm: new Date(0),
formatDate: (date: Date) => date.toISOString(),
};
const largeText = {
...message,
msg: 'Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text, Large text',
};
const noReplies = {
...message,
dcount: 0,
};
export default {
component: DiscussionsListItem,
};
export const Basic = () => <DiscussionsListItem {...(message as any)} />;
export const LargeText = () => <DiscussionsListItem {...(largeText as any)} />;
export const NoReplies = () => <DiscussionsListItem {...(noReplies as any)} />;

View File

@@ -0,0 +1,88 @@
import type { IDiscussionMessage } from '@rocket.chat/core-typings';
import {
Box,
Message,
MessageLeftContainer,
MessageContainer,
MessageHeader,
MessageName,
MessageTimestamp,
MessageBody,
MessageBlock,
MessageMetrics,
MessageMetricsItem,
MessageMetricsItemLabel,
MessageMetricsItemIcon,
} from '@rocket.chat/fuselage';
import { MessageAvatar } from '@rocket.chat/ui-avatar';
import type { ComponentProps, ReactNode } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import Emoji from '../../../../../components/Emoji';
import { clickableItem } from '../../../../../lib/clickableItem';
export type DiscussionListItemProps = {
_id: IDiscussionMessage['_id'];
msg: ReactNode;
dcount: number;
dlm: Date | undefined;
formatDate: (date: Date) => string;
username: IDiscussionMessage['u']['username'];
name?: IDiscussionMessage['u']['name'];
ts: IDiscussionMessage['ts'];
emoji: IDiscussionMessage['emoji'];
} & Omit<ComponentProps<typeof Box>, 'is'>;
const DiscussionListItem = ({
_id,
msg,
username,
name = username,
ts,
dcount,
formatDate,
dlm,
className = [],
emoji,
...props
}: DiscussionListItemProps) => {
const { t } = useTranslation();
return (
<Box is={Message} {...props} className={className} paddingBlockStart={16} paddingBlockEnd={8}>
<MessageLeftContainer>
<MessageAvatar emoji={emoji ? <Emoji emojiHandle={emoji} fillContainer /> : undefined} username={username} size='x36' />
</MessageLeftContainer>
<MessageContainer>
<MessageHeader>
<MessageName title={username}>{name}</MessageName>
<MessageTimestamp>{formatDate(ts)}</MessageTimestamp>
</MessageHeader>
<MessageBody clamp={2}>{msg}</MessageBody>
<MessageBlock>
<MessageMetrics>
{!dcount && (
<MessageMetricsItem>
<MessageMetricsItemLabel>{t('No_messages_yet')}</MessageMetricsItemLabel>
</MessageMetricsItem>
)}
{!!dcount && (
<MessageMetricsItem>
<MessageMetricsItemIcon name='discussion' />
<MessageMetricsItemLabel>{dcount}</MessageMetricsItemLabel>
</MessageMetricsItem>
)}
{!!dcount && (
<MessageMetricsItem>
<MessageMetricsItemIcon name='clock' />
<MessageMetricsItemLabel>{dlm ? formatDate(dlm) : undefined}</MessageMetricsItemLabel>
</MessageMetricsItem>
)}
</MessageMetrics>
</MessageBlock>
</MessageContainer>
</Box>
);
};
export default memo(clickableItem(DiscussionListItem));

View File

@@ -0,0 +1 @@
export { default } from './DiscussionsListContextBar';

View File

@@ -0,0 +1,63 @@
import type { IDiscussionMessage, IMessage } from '@rocket.chat/core-typings';
import { escapeRegExp } from '@rocket.chat/tools';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInfiniteMessageQueryUpdates } from '../../../../hooks/useInfiniteMessageQueryUpdates';
import { roomsQueryKeys } from '../../../../lib/queryKeys';
import { getConfig } from '../../../../lib/utils/getConfig';
import { mapMessageFromApi } from '../../../../lib/utils/mapMessageFromApi';
export const useDiscussionsList = ({ rid, text }: { rid: IMessage['rid']; text?: string }) => {
const getDiscussions = useEndpoint('GET', '/v1/chat.getDiscussions');
const count = parseInt(`${getConfig('discussionListSize', 10)}`, 10);
useInfiniteMessageQueryUpdates({
queryKey: roomsQueryKeys.discussions(rid, { text }),
roomId: rid,
// Replicates the filtering done server-side
filter: (message): message is IDiscussionMessage => {
if (!('drid' in message)) {
return false;
}
if (text) {
const regex = new RegExp(escapeRegExp(text), 'i');
if (!regex.test(message.msg)) {
return false;
}
}
return true;
},
});
return useInfiniteQuery({
queryKey: roomsQueryKeys.discussions(rid, { text }),
queryFn: async ({ pageParam: offset }) => {
const { messages, total } = await getDiscussions({
roomId: rid,
text,
offset,
count,
});
return {
items: messages.map(mapMessageFromApi) as IDiscussionMessage[],
itemCount: total,
};
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
// FIXME: This is an estimation, as discussions can be created or removed while paginating
// Ideally, the server should return the next offset to use or the pagination should be done using "createdAt" or "updatedAt"
const loadedItemsCount = allPages.reduce((acc, page) => acc + page.items.length, 0);
return loadedItemsCount < lastPage.itemCount ? loadedItemsCount : undefined;
},
select: ({ pages }) => ({
items: pages.flatMap((page) => page.items),
itemCount: pages.at(-1)?.itemCount ?? 0,
}),
});
};