Skip to content
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Callout, Box, MessageDivider, Throbber } from '@rocket.chat/fuselage';
import { MessageTypes } from '@rocket.chat/message-types';
import { Callout, Box, Throbber } from '@rocket.chat/fuselage';
import {
ContextualbarClose,
ContextualbarContent,
Expand All @@ -8,43 +7,33 @@ import {
ContextualbarIcon,
ContextualbarSection,
ContextualbarDialog,
VirtualizedScrollbars,
ContextualbarEmptyContent,
} from '@rocket.chat/ui-client';
import { useRoomToolbox, useUserPreference, useSetting } from '@rocket.chat/ui-contexts';
import { useState, memo, Fragment, useId } from 'react';
import { useRoomToolbox, useUserPreference } from '@rocket.chat/ui-contexts';
import { memo, useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';

import MessageSearch from './components/MessageSearch';
import MessageSearchForm from './components/MessageSearchForm';
import { useMessageSearchProviderQuery } from './hooks/useMessageSearchProviderQuery';
import { useMessageSearchQuery } from './hooks/useMessageSearchQuery';
import ResultsLiveRegion from '../../../../components/ResultsLiveRegion';
import RoomMessage from '../../../../components/message/variants/RoomMessage';
import SystemMessage from '../../../../components/message/variants/SystemMessage';
import { useFormatDate } from '../../../../hooks/useFormatDate';
import MessageListErrorBoundary from '../../MessageList/MessageListErrorBoundary';
import { isMessageNewDay } from '../../MessageList/lib/isMessageNewDay';
import MessageListProvider from '../../MessageList/providers/MessageListProvider';
import { useRoomSubscription } from '../../contexts/RoomContext';

// TODO: Refactor this component to isolate the data from the visual
const MessageSearchTab = () => {
const { t } = useTranslation();
const searchListId = useId();
const formatDate = useFormatDate();
const { closeTab } = useRoomToolbox();
const pageSize = useSetting('PageSize', 10);

const [limit, setLimit] = useState(pageSize);
const subscription = useRoomSubscription();
const showUserAvatar = !!useUserPreference<boolean>('displayAvatars');

const providerQuery = useMessageSearchProviderQuery();

const [{ searchText, globalSearch }, handleSearch] = useState({ searchText: '', globalSearch: false });
const { isSuccess, data: messageSearchData, isPending } = useMessageSearchQuery({ searchText, limit, globalSearch });
const itemCount = messageSearchData?.length ?? 0;
const { isPending, isSuccess, data, fetchNextPage } = useMessageSearchQuery({ searchText, globalSearch });
const items = data?.items || [];
const itemCount = data?.itemCount ?? 0;
const subscription = useRoomSubscription();
const showUserAvatar = !!useUserPreference<boolean>('displayAvatars');
const formatDate = useFormatDate();

return (
<ContextualbarDialog>
Expand All @@ -64,58 +53,19 @@ const MessageSearchTab = () => {
<>
{searchText && isPending && <Throbber />}
{isSuccess && (
<Box id={searchListId} display='flex' flexDirection='column' flexGrow={1} flexShrink={1} flexBasis={0}>
{messageSearchData.length === 0 && <ContextualbarEmptyContent title={t('No_results_found')} />}
{messageSearchData.length > 0 && (
<MessageListErrorBoundary>
<MessageListProvider>
<Box is='section' display='flex' flexDirection='column' flexGrow={1} flexShrink={1} flexBasis='auto' height='full'>
<VirtualizedScrollbars>
<Virtuoso
totalCount={messageSearchData.length}
overscan={25}
data={messageSearchData}
itemContent={(index, message) => {
const previous = messageSearchData[index - 1];

const newDay = isMessageNewDay(message, previous);

const system = MessageTypes.isSystemMessage(message);

const unread = subscription?.tunread?.includes(message._id) ?? false;
const mention = subscription?.tunreadUser?.includes(message._id) ?? false;
const all = subscription?.tunreadGroup?.includes(message._id) ?? false;

return (
<Fragment key={message._id}>
{newDay && <MessageDivider>{formatDate(message.ts)}</MessageDivider>}

{system ? (
<SystemMessage message={message} showUserAvatar={showUserAvatar} />
) : (
<RoomMessage
message={message}
sequential={false}
unread={unread}
mention={mention}
all={all}
context='search'
searchText={searchText}
showUserAvatar={showUserAvatar}
/>
)}
</Fragment>
);
}}
endReached={() => {
setLimit((limit) => limit + pageSize);
}}
/>
</VirtualizedScrollbars>
</Box>
</MessageListProvider>
</MessageListErrorBoundary>
)}
<Box id={searchListId} w='full' h='full' overflow='hidden' flexShrink={1}>
<MessageSearch
items={items}
itemCount={itemCount}
isPending={isPending}
isSuccess={isSuccess}
fetchNextPage={fetchNextPage}
subscription={subscription}
showUserAvatar={showUserAvatar}
formatDate={formatDate}
searchText={searchText}
noResultsTitle={t('No_results_found')}
/>
</Box>
)}
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { composeStories } from '@storybook/react';
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import type { PropsWithChildren, ReactNode } from 'react';

import * as stories from './MessageSearch.stories';
import type { MessageSearchItem } from '../hooks/useMessageSearchQuery';

jest.mock('../../../../../components/PaginatedVirtualList', () => ({
PaginatedVirtualList: ({
items,
totalCount,
renderItem,
}: {
items: MessageSearchItem[];
totalCount: number;
renderItem: (item: MessageSearchItem, index: number) => ReactNode;
}) => (
<ul data-testid='message-search-list' data-total-count={totalCount}>
{items.map((item, index) => (
<li key={item._id}>{renderItem(item, index)}</li>
))}
</ul>
),
}));

jest.mock('../../../../../../app/utils/client', () => ({
getURL: (url: string) => url,
}));

jest.mock('../../../MessageList/providers/MessageListProvider', () => ({ children }: PropsWithChildren) => <>{children}</>);

const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);

const trimTrailingWhitespace = (node: HTMLElement): HTMLElement => {
const clone = node.cloneNode(true) as HTMLElement;
const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT);
const nodesToRemove: Node[] = [];

for (let textNode = walker.nextNode(); textNode; textNode = walker.nextNode()) {
const textContent = textNode.textContent ?? '';
if (/^\s+$/.test(textContent)) {
nodesToRemove.push(textNode);
continue;
}

textNode.textContent = textContent.replace(/[ \t]+$/gm, '');
}

nodesToRemove.forEach((textNode) => textNode.parentNode?.removeChild(textNode));

return clone;
};

test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
const { baseElement } = render(<Story />);
expect(trimTrailingWhitespace(baseElement)).toMatchSnapshot();
});

test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);

const results = await axe(container, {
rules: {
'nested-interactive': { enabled: false },
'aria-required-parent': { enabled: false },
},
});
expect(results).toHaveNoViolations();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { Contextualbar } from '@rocket.chat/ui-client';
import type { Meta, StoryObj } from '@storybook/react';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';

import MessageSearch from './MessageSearch';
import FakeRoomProvider from '../../../../../../tests/mocks/client/FakeRoomProvider';
import { createFakeMessageWithMd, createFakeRoom, createFakeSubscription } from '../../../../../../tests/mocks/data';
import type { MessageSearchItem } from '../hooks/useMessageSearchQuery';

const room = createFakeRoom({ _id: 'room-id', t: 'c', name: 'general', fname: 'General' });
const subscription = createFakeSubscription({
rid: room._id,
tunread: ['message-2'],
tunreadUser: ['message-2'],
tunreadGroup: [],
});

const fetchNextPage = (async () =>
({}) as Awaited<ReturnType<UseInfiniteQueryResult['fetchNextPage']>>) satisfies UseInfiniteQueryResult['fetchNextPage'];

const formatDate = (date: Date | string | number): string =>
new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(date));

const createMessage = (overrides: Partial<IMessage>): MessageSearchItem =>
createFakeMessageWithMd({
rid: room._id,
u: {
_id: 'user-id',
username: 'ana.silva',
name: 'Ana Silva',
},
...overrides,
}) as MessageSearchItem;

const searchResults = [
createMessage({
_id: 'date-message-1',
msg: 'Initial search result from Monday.',
ts: new Date('2026-06-08T10:00:00.000Z'),
}),
createMessage({
_id: 'system-message-1',
msg: 'Sam Chen joined the room',
t: 'uj',
ts: new Date('2026-06-09T09:00:00.000Z'),
}),
createMessage({
_id: 'system-message-2',
msg: 'Room topic changed to Release coordination',
t: 'room_changed_topic',
ts: new Date('2026-06-09T09:05:00.000Z'),
}),
createMessage({
_id: 'date-message-2',
msg: 'Follow-up result from Tuesday.',
ts: new Date('2026-06-09T10:00:00.000Z'),
}),
createMessage({
_id: 'message-1',
msg: 'Can you share the deployment checklist?',
ts: new Date('2026-06-09T14:15:00.000Z'),
}),
createMessage({
_id: 'message-2',
msg: 'The checklist is attached to the release room topic.',
ts: new Date('2026-06-09T14:18:00.000Z'),
u: {
_id: 'user-id-2',
username: 'sam.chen',
name: 'Sam Chen',
},
}),
createMessage({
_id: 'message-3',
msg: 'I found the rollback notes as well.',
ts: new Date('2026-06-09T14:22:00.000Z'),
}),
createMessage({
_id: 'date-message-3',
msg: 'Final result from Wednesday.',
ts: new Date('2026-06-10T10:00:00.000Z'),
}),
];

const meta = {
component: MessageSearch,
parameters: {
layout: 'fullscreen',
actions: { argTypesRegex: '^on.*' },
},
decorators: [
mockAppRoot().withJohnDoe().withRoom(room).withSubscription(subscription).buildStoryDecorator(),
(fn) => (
<FakeRoomProvider roomOverrides={room} subscriptionOverrides={subscription}>
<Contextualbar height='100vh'>
<Box w='full' h='full' overflow='hidden'>
{fn()}
</Box>
</Contextualbar>
</FakeRoomProvider>
),
],
args: {
itemCount: 0,
isPending: false,
isSuccess: true,
fetchNextPage,
subscription,
showUserAvatar: true,
formatDate,
searchText: 'release',
noResultsTitle: 'No results found',
},
} satisfies Meta<typeof MessageSearch>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Empty: Story = {
args: {
items: [],
itemCount: 0,
},
};

export const WithResults: Story = {
args: {
items: searchResults,
itemCount: searchResults.length,
},
};
Loading