diff --git a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/MessageSearchTab.tsx b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/MessageSearchTab.tsx index c804515c61ac7..a94eb0e29431f 100644 --- a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/MessageSearchTab.tsx +++ b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/MessageSearchTab.tsx @@ -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, @@ -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('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('displayAvatars'); + const formatDate = useFormatDate(); return ( @@ -64,58 +53,19 @@ const MessageSearchTab = () => { <> {searchText && isPending && } {isSuccess && ( - - {messageSearchData.length === 0 && } - {messageSearchData.length > 0 && ( - - - - - { - 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 ( - - {newDay && {formatDate(message.ts)}} - - {system ? ( - - ) : ( - - )} - - ); - }} - endReached={() => { - setLimit((limit) => limit + pageSize); - }} - /> - - - - - )} + + )} diff --git a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearch.spec.tsx b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearch.spec.tsx new file mode 100644 index 0000000000000..a071e3b34d6c9 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearch.spec.tsx @@ -0,0 +1,106 @@ +import type { IMessage, ISubscription } from '@rocket.chat/core-typings'; +import { render, screen } from '@testing-library/react'; + +import MessageSearch from './MessageSearch'; +import type { MessageSearchItem } from '../hooks/useMessageSearchQuery'; + +jest.mock('../../../../../components/PaginatedVirtualList', () => ({ + PaginatedVirtualList: ({ + items, + totalCount, + renderItem, + }: { + items: MessageSearchItem[]; + totalCount: number; + renderItem: (item: MessageSearchItem, index: number) => React.ReactNode; + }) => ( +
+ {items.map((item, index) => ( +
{renderItem(item, index)}
+ ))} +
+ ), +})); + +jest.mock('../../../../../components/message/variants/RoomMessage', () => ({ message }: { message: IMessage }) => ( +
{message.msg}
+)); + +jest.mock('../../../../../components/message/variants/SystemMessage', () => ({ message }: { message: IMessage }) => ( +
{message.msg}
+)); + +jest.mock('../../../MessageList/MessageListErrorBoundary', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('../../../MessageList/providers/MessageListProvider', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useTranslation: () => (key: string) => key, +})); + +const createMessage = (id: string, overrides: Partial = {}): MessageSearchItem => + ({ + _id: id, + rid: 'room-id', + user: 'testuser', + msg: `Message ${id}`, + ts: new Date('2026-03-22T10:00:00.000Z'), + u: { _id: 'user-id', username: 'testuser', name: 'Test User' }, + _updatedAt: new Date('2026-03-22T10:00:00.000Z'), + ...overrides, + }) as MessageSearchItem; + +const subscription = { + tunread: ['message-1'], + tunreadUser: ['message-1'], + tunreadGroup: ['message-1'], +} as ISubscription; + +const defaultProps = { + items: [], + itemCount: 0, + isPending: false, + isSuccess: true, + fetchNextPage: jest.fn(), + subscription, + showUserAvatar: true, + formatDate: () => 'formatted-date', + searchText: 'hello', + noResultsTitle: 'No_results_found', +}; + +describe('MessageSearch', () => { + it('renders the empty state when no messages are returned', () => { + render(); + + expect(screen.getByText('No_results_found')).toBeInTheDocument(); + }); + + it('renders nothing until the search query succeeds', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders room and system messages with date dividers', () => { + const roomMessage = createMessage('message-1'); + const systemMessage = createMessage('message-2', { + ts: new Date('2026-03-23T10:00:00.000Z'), + t: 'au', + msg: 'System event', + }); + + render(); + + expect(screen.getByTestId('room-message')).toHaveTextContent('Message message-1'); + expect(screen.getByTestId('system-message')).toHaveTextContent('System event'); + expect(screen.getAllByText('formatted-date')).toHaveLength(2); + }); +}); diff --git a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearch.stories.tsx b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearch.stories.tsx new file mode 100644 index 0000000000000..c51494f9e5460 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearch.stories.tsx @@ -0,0 +1,155 @@ +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 { action } from '@storybook/addon-actions'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { UseInfiniteQueryResult } from '@tanstack/react-query'; + +import MessageSearch from './MessageSearch'; +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 fetchNextPageAction = action('fetchNextPage'); +const fetchNextPage = (async (options) => { + fetchNextPageAction(options); + return {} as Awaited>; +}) satisfies UseInfiniteQueryResult['fetchNextPage']; + +const formatDate = (date: Date | string | number): string => new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(new Date(date)); + +const createMessage = (overrides: Partial): MessageSearchItem => + createFakeMessageWithMd({ + rid: room._id, + u: { + _id: 'user-id', + username: 'ana.silva', + name: 'Ana Silva', + }, + ...overrides, + }) as MessageSearchItem; + +const messages = [ + 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'), + }), +]; + +const systemMessages = [ + 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'), + }), +]; + +const multipleDateMessages = [ + createMessage({ + _id: 'date-message-1', + msg: 'Initial search result from Monday.', + ts: new Date('2026-06-08T10:00:00.000Z'), + }), + createMessage({ + _id: 'date-message-2', + msg: 'Follow-up result from Tuesday.', + ts: new Date('2026-06-09T10:00: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) => ( + + + {fn()} + + + ), + ], + args: { + itemCount: 0, + isPending: false, + isSuccess: true, + fetchNextPage, + subscription, + showUserAvatar: true, + formatDate, + searchText: 'release', + noResultsTitle: 'No results found', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { + items: [], + itemCount: 0, + }, +}; + +export const Messages: Story = { + args: { + items: messages, + itemCount: messages.length, + }, +}; + +export const SystemMessages: Story = { + args: { + items: systemMessages, + itemCount: systemMessages.length, + }, +}; + +export const MultipleDateGroups: Story = { + args: { + items: multipleDateMessages, + itemCount: multipleDateMessages.length, + }, +}; diff --git a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearch.tsx b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearch.tsx new file mode 100644 index 0000000000000..645bb35e45e8c --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/components/MessageSearch.tsx @@ -0,0 +1,111 @@ +import type { ISubscription } from '@rocket.chat/core-typings'; +import { Box, MessageDivider } from '@rocket.chat/fuselage'; +import { MessageTypes } from '@rocket.chat/message-types'; +import { ContextualbarEmptyContent } from '@rocket.chat/ui-client'; +import type { UseInfiniteQueryResult } from '@tanstack/react-query'; +import type { ReactElement } from 'react'; +import { Fragment, memo } from 'react'; + +import { PaginatedVirtualList } from '../../../../../components/PaginatedVirtualList'; +import RoomMessage from '../../../../../components/message/variants/RoomMessage'; +import SystemMessage from '../../../../../components/message/variants/SystemMessage'; +import MessageListErrorBoundary from '../../../MessageList/MessageListErrorBoundary'; +import { isMessageNewDay } from '../../../MessageList/lib/isMessageNewDay'; +import MessageListProvider from '../../../MessageList/providers/MessageListProvider'; +import type { MessageSearchItem } from '../hooks/useMessageSearchQuery'; + +type MessageSearchProps = { + items: MessageSearchItem[]; + itemCount: number; + isPending: boolean; + isSuccess: boolean; + fetchNextPage: UseInfiniteQueryResult['fetchNextPage']; + subscription: ISubscription | undefined; + showUserAvatar: boolean; + formatDate: (date: Date | string | number) => string; + searchText: string; + noResultsTitle: string; +}; + +const MessageSearch = ({ + items, + itemCount, + isPending, + isSuccess, + fetchNextPage, + subscription, + showUserAvatar, + formatDate, + searchText, + noResultsTitle, +}: MessageSearchProps): ReactElement => { + if (!isSuccess) { + return <>; + } + + return ( + <> + {items.length === 0 && } + {items.length > 0 && ( + + + + + { + const previous = items[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 ( + + {newDay && {formatDate(message.ts)}} + + {system ? ( + + ) : ( + + )} + + ); + }} + /> + + + + + )} + + ); +}; + +export default memo(MessageSearch); diff --git a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/hooks/useMessageSearchQuery.ts b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/hooks/useMessageSearchQuery.ts index ffa22b4ae69a6..3687ad8d724a7 100644 --- a/apps/meteor/client/views/room/contextualBar/MessageSearchTab/hooks/useMessageSearchQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/MessageSearchTab/hooks/useMessageSearchQuery.ts @@ -1,30 +1,39 @@ -import { useMethod, useTranslation, useUserId } from '@rocket.chat/ui-contexts'; -import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { useMethod, useSetting, useTranslation, useUserId } from '@rocket.chat/ui-contexts'; +import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'; import { useRoom } from '../../../contexts/RoomContext'; -export const useMessageSearchQuery = ({ - searchText, - limit, - globalSearch, -}: { - searchText: string; - limit: number; - globalSearch: boolean; -}) => { +export type MessageSearchItem = NonNullable>['message']>['docs'][number]; + +export const useMessageSearchQuery = ({ searchText, globalSearch }: { searchText: string; globalSearch: boolean }) => { const uid = useUserId(); const room = useRoom(); + const pageSize = useSetting('PageSize', 10); const t = useTranslation(); const searchMessages = useMethod('rocketchatSearch.search'); - return useQuery({ - queryKey: ['rooms', room._id, 'message-search', { uid, rid: room._id, searchText, limit, globalSearch }] as const, - - queryFn: async () => { + return useInfiniteQuery({ + queryKey: ['rooms', room._id, 'message-search', { uid, rid: room._id, searchText, globalSearch }] as const, + queryFn: async ({ pageParam: limit }) => { const result = await searchMessages(searchText, { uid, rid: room._id }, { limit, searchAll: globalSearch }); - return result.message?.docs ?? []; + const items = result.message?.docs ?? []; + + return { + items, + itemCount: items.length >= limit ? items.length + 1 : items.length, + }; + }, + initialPageParam: pageSize, + getNextPageParam: (lastPage, _allPages, lastPageParam) => { + if (lastPage.items.length < lastPageParam) { + return undefined; + } + + return lastPageParam + pageSize; }, + select: ({ pages }) => pages.at(-1) ?? { items: [], itemCount: 0 }, placeholderData: keepPreviousData, meta: { errorToastMessage: t('Search_message_search_failed'),