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,145 @@
import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings';
import { isEditedMessage, isThreadMainMessage } from '@rocket.chat/core-typings';
import { Box, CheckBox, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { clientCallbacks, ContextualbarContent } from '@rocket.chat/ui-client';
import { useEndpoint, useTranslation, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts';
import { useState, useEffect, useCallback, useId } from 'react';
import ThreadMessageList from './ThreadMessageList';
import MessageListErrorBoundary from '../../../MessageList/MessageListErrorBoundary';
import DropTargetOverlay from '../../../body/DropTargetOverlay';
import { useFileUploadDropTarget } from '../../../body/hooks/useFileUploadDropTarget';
import ComposerContainer from '../../../composer/ComposerContainer';
import RoomComposer from '../../../composer/RoomComposer/RoomComposer';
import { useChat } from '../../../contexts/ChatContext';
import { useRoom, useRoomSubscription } from '../../../contexts/RoomContext';
import { DateListProvider } from '../../../providers/DateListProvider';
export type ThreadChatProps = {
mainMessage: IThreadMainMessage;
};
const ThreadChat = ({ mainMessage }: ThreadChatProps) => {
const chat = useChat();
if (!chat) {
throw new Error('No ChatContext provided');
}
const sendToChannelPreference = useUserPreference<'always' | 'never' | 'default'>('alsoSendThreadToChannel');
const [sendToChannel, setSendToChannel] = useState(() => {
switch (sendToChannelPreference) {
case 'always':
return true;
case 'never':
return false;
default:
return !mainMessage.tcount;
}
});
const handleSend = useCallback((): void => {
if (sendToChannelPreference === 'default') {
setSendToChannel(false);
}
}, [sendToChannelPreference]);
const { closeTab } = useRoomToolbox();
const handleComposerEscape = useCallback((): void => {
closeTab();
}, [closeTab]);
const [fileUploadTriggerProps, fileUploadOverlayProps] = useFileUploadDropTarget();
const handleNavigateToPreviousMessage = useCallback((): void => {
chat?.messageEditing.toPreviousMessage();
}, [chat?.messageEditing]);
const handleNavigateToNextMessage = useCallback((): void => {
chat?.messageEditing.toNextMessage();
}, [chat?.messageEditing]);
const room = useRoom();
const readThread = useEndpoint('POST', '/v1/chat.readThread');
useEffect(() => {
clientCallbacks.add(
'streamNewMessage',
(msg: IMessage) => {
if (room._id !== msg.rid || isEditedMessage(msg) || msg.tmid !== mainMessage._id) {
return;
}
void Promise.resolve(readThread({ tmid: mainMessage._id })).catch(() => undefined);
},
clientCallbacks.priority.MEDIUM,
`thread-${room._id}`,
);
return () => {
clientCallbacks.remove('streamNewMessage', `thread-${room._id}`);
};
}, [mainMessage._id, readThread, room._id]);
const subscription = useRoomSubscription();
const sendToChannelID = useId();
const t = useTranslation();
const [shouldJumpToBottom, setShouldJumpToBottom] = useState(true);
return (
<ContextualbarContent flexShrink={1} flexGrow={1} paddingInline={0} {...fileUploadTriggerProps}>
<DateListProvider>
<DropTargetOverlay {...fileUploadOverlayProps} />
<Box
is='section'
position='relative'
display='flex'
flexDirection='column'
flexGrow={1}
flexShrink={1}
flexBasis='auto'
height='full'
>
<MessageListErrorBoundary>
<ThreadMessageList
mainMessage={mainMessage}
shouldJumpToBottom={shouldJumpToBottom}
setShouldJumpToBottom={setShouldJumpToBottom}
/>
</MessageListErrorBoundary>
<RoomComposer aria-label={t('Thread_composer')}>
<ComposerContainer
tmid={mainMessage._id}
threadExists={isThreadMainMessage(mainMessage)}
subscription={subscription}
onSend={handleSend}
onEscape={handleComposerEscape}
onNavigateToPreviousMessage={handleNavigateToPreviousMessage}
onNavigateToNextMessage={handleNavigateToNextMessage}
tshow={sendToChannel}
>
<Field marginBlock={8}>
<FieldRow justifyContent='initial'>
<CheckBox
id={sendToChannelID}
checked={sendToChannel}
onChange={() => setSendToChannel((checked) => !checked)}
name='alsoSendThreadToChannel'
/>
<FieldLabel marginInlineStart='x8' htmlFor={sendToChannelID} color='annotation' fontScale='p2'>
{t('Also_send_to_channel')}
</FieldLabel>
</FieldRow>
</Field>
</ComposerContainer>
</RoomComposer>
</Box>
</DateListProvider>
</ContextualbarContent>
);
};
export default ThreadChat;

View File

@@ -0,0 +1,75 @@
import type { IThreadMainMessage } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Palette } from '@rocket.chat/fuselage';
import { useSetting, useUserId } from '@rocket.chat/ui-contexts';
import type { MouseEvent } from 'react';
import { useCallback, memo } from 'react';
import { useTranslation } from 'react-i18next';
import ThreadListMessage from './ThreadListMessage';
import { useDecryptedMessage } from '../../../../../hooks/useDecryptedMessage';
import { normalizeThreadMessage } from '../../../../../lib/normalizeThreadMessage';
export type ThreadListItemProps = {
thread: IThreadMainMessage;
unread: string[];
unreadUser: string[];
unreadGroup: string[];
hasDraft?: boolean;
onClick: (tmid: IThreadMainMessage['_id']) => void;
};
const ThreadListItem = ({ thread, unread, unreadUser, unreadGroup, hasDraft, onClick }: ThreadListItemProps) => {
const { t } = useTranslation();
const uid = useUserId();
const decryptedMsg = useDecryptedMessage(thread);
const msg = normalizeThreadMessage({ ...thread, msg: decryptedMsg }, t);
const { name = thread.u.username } = thread.u;
const following = !!uid && (thread.replies?.includes(uid) ?? false);
const showRealNames = (useSetting('UI_Use_Real_Name') as boolean | undefined) ?? false;
const handleListItemClick = useCallback(
(event: MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
onClick(thread._id);
},
[onClick, thread._id],
);
return (
<ThreadListMessage
className={css`
cursor: pointer;
&:hover,
&:focus {
background: ${Palette.surface['surface-hover']};
}
border-bottom: 1px solid ${Palette.stroke['stroke-extra-light']} !important;
`}
tabIndex={0}
_id={thread._id}
replies={thread.tcount ?? 0}
tlm={thread.tlm}
ts={thread.ts}
participants={thread.replies}
name={showRealNames ? name : thread.u.username}
username={thread.u.username}
unread={unread.includes(thread._id)}
mention={unreadUser.includes(thread._id)}
all={unreadGroup.includes(thread._id)}
following={following}
hasDraft={hasDraft}
data-id={thread._id}
msg={msg ?? ''}
rid={thread.rid}
onClick={handleListItemClick}
emoji={thread?.emoji}
/>
);
};
export default memo(ThreadListItem);

View File

@@ -0,0 +1,93 @@
import type { IMessage } from '@rocket.chat/core-typings';
import {
Message,
MessageLeftContainer,
MessageContainer,
MessageHeader,
MessageName,
MessageTimestamp,
MessageBody,
MessageContainerFixed,
MessageStatusIndicatorItem,
Box,
} 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 ThreadListMetrics from './ThreadListMetrics';
import Emoji from '../../../../../components/Emoji';
import ThreadMetricsFollow from '../../../../../components/message/content/ThreadMetricsFollow';
import ThreadMetricsUnreadBadge from '../../../../../components/message/content/ThreadMetricsUnreadBadge';
import { useTimeAgo } from '../../../../../hooks/useTimeAgo';
export type ThreadListMessageProps = {
_id: IMessage['_id'];
msg: ReactNode;
following: boolean;
username: IMessage['u']['username'];
name?: IMessage['u']['name'];
ts: IMessage['ts'];
replies: number;
participants: string[] | undefined;
rid: IMessage['rid'];
unread: boolean;
mention: boolean;
all: boolean;
tlm: Date;
emoji: IMessage['emoji'];
hasDraft?: boolean;
} & Omit<ComponentProps<typeof Box>, 'is'>;
const ThreadListMessage = ({
_id,
msg,
following,
username,
name = username,
ts,
replies,
participants,
unread,
rid,
mention,
all,
tlm,
className = [],
emoji,
hasDraft,
...props
}: ThreadListMessageProps) => {
const { t } = useTranslation();
const formatDate = useTimeAgo();
return (
<Box className={className}>
<Box paddingBlockStart={16} is={Message} {...props}>
<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>
{hasDraft && <MessageStatusIndicatorItem name='pencil' title={t('Unfinished_thread_message')} />}
</MessageHeader>
<MessageBody clamp={2}>{msg}</MessageBody>
<ThreadListMetrics lm={tlm} participants={participants || []} counter={replies} />
</MessageContainer>
<MessageContainerFixed>
<ThreadMetricsFollow following={following} mid={_id} rid={rid} mention={false} unread={false} all={false} />
{unread && (
<Box marginBlockStart={24}>
<ThreadMetricsUnreadBadge unread={unread} mention={mention} all={all} />
</Box>
)}
</MessageContainerFixed>
</Box>
</Box>
);
};
export default memo(ThreadListMessage);

View File

@@ -0,0 +1,40 @@
import { MessageMetricsItem, MessageBlock, MessageMetrics, MessageMetricsItemIcon, MessageMetricsItemLabel } from '@rocket.chat/fuselage';
import { useResizeObserver } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import ThreadMetricsParticipants from '../../../../../components/message/content/ThreadMetricsParticipants';
import { useTimeAgo } from '../../../../../hooks/useTimeAgo';
type ThreadMetricsProps = {
lm: Date;
counter: number;
participants: Array<string>;
};
const ThreadListMetrics = ({ counter, participants, lm }: ThreadMetricsProps) => {
const t = useTranslation();
const format = useTimeAgo();
const { ref, borderBoxSize } = useResizeObserver<HTMLDivElement>();
const isSmall = (borderBoxSize.inlineSize || Infinity) < 200;
return (
<MessageBlock ref={ref}>
<MessageMetrics>
{participants?.length > 0 && <ThreadMetricsParticipants participants={participants} />}
<MessageMetricsItem title={t('Last_message__date__', { date: format(lm) })}>
<MessageMetricsItemIcon name='thread' />
{isSmall ? (
<MessageMetricsItemLabel>{t('__count__replies', { count: counter })}</MessageMetricsItemLabel>
) : (
<MessageMetricsItemLabel>{t('__count__replies__date__', { count: counter, date: format(lm) })}</MessageMetricsItemLabel>
)}
</MessageMetricsItem>
</MessageMetrics>
</MessageBlock>
);
};
export default ThreadListMetrics;

View File

@@ -0,0 +1,77 @@
import type { IThreadMainMessage, IThreadMessage } from '@rocket.chat/core-typings';
import { Box, Bubble, MessageDivider } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import SystemMessage from '../../../../../components/message/variants/SystemMessage';
import ThreadMessage from '../../../../../components/message/variants/ThreadMessage';
import { useFormatDate } from '../../../../../hooks/useFormatDate';
import { isMessageNewDay } from '../../../MessageList/lib/isMessageNewDay';
import { useRoomSubscription } from '../../../contexts/RoomContext';
import { useDateRef } from '../../../providers/DateListProvider';
type ThreadMessageProps = {
message: IThreadMessage | IThreadMainMessage;
previous: IThreadMessage | IThreadMainMessage;
sequential: boolean;
shouldShowAsSequential: boolean;
showUserAvatar: boolean;
firstUnread: boolean;
system: boolean;
};
export const ThreadMessageItem = ({
message,
previous,
shouldShowAsSequential,
showUserAvatar,
firstUnread,
system,
}: ThreadMessageProps) => {
const { t } = useTranslation();
const formatDate = useFormatDate();
const ref = useDateRef();
const newDay = isMessageNewDay(message, previous);
const showDivider = newDay || firstUnread;
const subscription = useRoomSubscription();
const ignoredUser = Boolean(subscription?.ignored?.includes(message.u._id) || (message as { ignored?: boolean }).ignored);
return (
<>
{showDivider && (
<Box
role='listitem'
ref={ref}
data-id={message.ts}
{...(newDay && {
'data-time': new Date(message.ts)
.toISOString()
.replaceAll(/[-T:.]/g, '')
.substring(0, 8),
})}
>
<MessageDivider unreadLabel={firstUnread ? t('Unread_Messages').toLowerCase() : undefined}>
{newDay && (
<Bubble small secondary>
{formatDate(message.ts)}
</Bubble>
)}
</MessageDivider>
</Box>
)}
{system ? (
<SystemMessage message={message} showUserAvatar={showUserAvatar} />
) : (
<ThreadMessage
message={message}
sequential={shouldShowAsSequential}
unread={firstUnread}
showUserAvatar={showUserAvatar}
ignoredUser={ignoredUser}
/>
)}
</>
);
};

View File

@@ -0,0 +1,242 @@
import type { IMessage, IThreadMainMessage, IThreadMessage } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import type { HTMLAttributes, ReactNode } from 'react';
import { forwardRef } from 'react';
import ThreadMessageList from './ThreadMessageList';
import { createFakeMessage, createFakeRoom } from '../../../../../../tests/mocks/data';
import { useThreadMessagesQuery } from '../hooks/useThreadMessagesQuery';
const room = createFakeRoom({ _id: 'room-id', t: 'c' });
const mockVirtualizerHandle = {
scrollToIndex: jest.fn(),
findItemIndex: jest.fn(() => 0),
scrollOffset: 100,
scrollSize: 1000,
viewportSize: 300,
};
jest.mock('virtua', () => {
const { Children, forwardRef, useImperativeHandle } = jest.requireActual<typeof import('react')>('react');
return {
// `virtua` renders a plain container and wraps every child in a `div` of its own, so the list markup
// under test cannot rely on `ul`/`li` semantics.
VList: forwardRef(
(
{
children,
onScroll,
shift: _shift,
keepMounted: _keepMounted,
...props
}: {
children: ReactNode;
onScroll?: (offset: number) => void;
shift?: boolean;
keepMounted?: number[];
},
ref: any,
) => {
useImperativeHandle(ref, () => mockVirtualizerHandle);
return (
<div data-testid='thread-message-list' onScroll={() => onScroll?.(mockVirtualizerHandle.scrollOffset)} {...props}>
{Children.map(children, (child) => (child ? <div>{child}</div> : child))}
</div>
);
},
),
};
});
jest.mock('@rocket.chat/fuselage-hooks', () => ({
...jest.requireActual('@rocket.chat/fuselage-hooks'),
useDebouncedCallback: (callback: (...args: any[]) => void) => callback,
}));
jest.mock('@rocket.chat/ui-client', () => ({
clientCallbacks: {
add: jest.fn(),
remove: jest.fn(),
priority: { MEDIUM: 0 },
},
CustomVirtuaScrollbars: forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function CustomVirtuaScrollbars(
{ children, ...props },
ref,
) {
return (
<div {...props}>
<div ref={ref}>{children}</div>
<div data-testid='overlay-scrollbar-handle' />
</div>
);
}),
}));
jest.mock('../hooks/useThreadMessagesQuery', () => ({
useThreadMessagesQuery: jest.fn(),
}));
jest.mock('../../../hooks/useDateScroll', () => ({
useDateScroll: () => ({
bubbleRef: { current: null },
handleDateScroll: jest.fn(),
}),
}));
jest.mock('../../../contexts/RoomContext', () => ({
useRoom: () => room,
}));
jest.mock('../../../MessageList/hooks/useKeepAtBottom', () => ({
useKeepAtBottom: () => ({ keepAtBottomRef: jest.fn(), setKeepAtBottom: jest.fn() }),
}));
jest.mock('../../../hooks/useFirstUnreadMessageId', () => ({
useFirstUnreadMessageId: () => undefined,
}));
jest.mock('../../../hooks/useMessageListNavigation', () => ({
useMessageListNavigation: () => ({ messageListRef: { current: null } }),
}));
jest.mock('../../../MessageList/providers/MessageListProvider', () => ({ children }: { children: ReactNode }) => <>{children}</>);
jest.mock('../../../../../lib/utils/setMessageJumpQueryStringParameter', () => ({
setMessageJumpQueryStringParameter: jest.fn(),
}));
jest.mock('./ThreadMessageItem', () => ({
ThreadMessageItem: ({ message }: { message: IMessage }) => <div role='listitem'>{message._id}</div>,
}));
jest.mock('../../../BubbleDate', () => ({
BubbleDate: forwardRef(function BubbleDate(_, ref) {
return <time ref={ref as any} />;
}),
}));
const createThreadMessage = (index: number): IThreadMessage => {
const ts = new Date(Date.UTC(2026, 0, index));
return createFakeMessage<IThreadMessage>({
_id: `reply-${index}`,
rid: room._id,
tmid: 'thread-id',
msg: `reply ${index}`,
ts,
_updatedAt: ts,
u: {
_id: 'user-id',
username: 'user',
name: 'User',
},
});
};
describe('ThreadMessageList', () => {
it('fetches the previous page after a pointer interaction with the overlay scrollbar', async () => {
const user = userEvent.setup();
const fetchPreviousPage = jest.fn().mockResolvedValue(undefined);
const mainMessage = createFakeMessage<IThreadMainMessage>({
_id: 'thread-id',
rid: room._id,
msg: 'main message',
tcount: 1,
u: {
_id: 'user-id',
username: 'user',
name: 'User',
},
});
(useThreadMessagesQuery as jest.Mock).mockReturnValue({
data: { messages: [createThreadMessage(1)] },
isLoading: false,
fetchNextPage: jest.fn(),
hasNextPage: false,
isFetchingNextPage: false,
fetchPreviousPage,
hasPreviousPage: true,
isFetchingPreviousPage: false,
loadMessageAround: jest.fn(),
});
render(<ThreadMessageList mainMessage={mainMessage} shouldJumpToBottom setShouldJumpToBottom={jest.fn()} />, {
wrapper: mockAppRoot().withJohnDoe().withSetting('Message_GroupingPeriod', 300).withUserPreference('displayAvatars', true).build(),
});
fireEvent.scroll(screen.getByTestId('thread-message-list'));
expect(fetchPreviousPage).not.toHaveBeenCalled();
await user.pointer({ keys: '[MouseLeft>]', target: screen.getByTestId('overlay-scrollbar-handle') });
fireEvent.scroll(screen.getByTestId('thread-message-list'));
await user.pointer({ keys: '[/MouseLeft]' });
expect(fetchPreviousPage).toHaveBeenCalledTimes(1);
});
});
describe('ThreadMessageList accessibility', () => {
const mainMessage = createFakeMessage<IThreadMainMessage>({
_id: 'thread-id',
rid: room._id,
msg: 'main message',
tcount: 1,
u: {
_id: 'user-id',
username: 'user',
name: 'User',
},
});
const mockThreadMessagesQuery = (overrides: Record<string, unknown> = {}) => {
(useThreadMessagesQuery as jest.Mock).mockReturnValue({
data: { messages: [createThreadMessage(1), createThreadMessage(2)] },
isLoading: false,
fetchNextPage: jest.fn(),
hasNextPage: false,
isFetchingNextPage: false,
fetchPreviousPage: jest.fn(),
hasPreviousPage: false,
isFetchingPreviousPage: false,
loadMessageAround: jest.fn(),
...overrides,
});
};
const renderThreadMessageList = () =>
render(<ThreadMessageList mainMessage={mainMessage} shouldJumpToBottom={false} setShouldJumpToBottom={jest.fn()} />, {
wrapper: mockAppRoot().withJohnDoe().withSetting('Message_GroupingPeriod', 300).withUserPreference('displayAvatars', true).build(),
});
it('should render a labelled list exposing every message as a list item', () => {
mockThreadMessagesQuery();
renderThreadMessageList();
const list = screen.getByRole('list');
expect(list).toHaveAccessibleName();
// the two replies plus the main message
expect(within(list).getAllByRole('listitem')).toHaveLength(3);
});
const states: [string, Record<string, unknown>][] = [
['default', {}],
['loading', { isLoading: true, data: undefined }],
['loading previous messages', { hasPreviousPage: true, isFetchingPreviousPage: true }],
['loading next messages', { hasNextPage: true, isFetchingNextPage: true }],
['able to load next messages', { hasNextPage: true }],
];
it.each(states)('should have no accessibility violations when %s', async (_state, overrides) => {
mockThreadMessagesQuery(overrides);
const { container } = renderThreadMessageList();
expect(await axe(container)).toHaveNoViolations();
});
});

View File

@@ -0,0 +1,460 @@
import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings';
import { isEditedMessage } from '@rocket.chat/core-typings';
import { useDebouncedCallback, useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
import { MessageTypes } from '@rocket.chat/message-types';
import { isTruthy } from '@rocket.chat/tools';
import { clientCallbacks, CustomVirtuaScrollbars } from '@rocket.chat/ui-client';
import { useSearchParameter, useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts';
import { differenceInSeconds } from 'date-fns';
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { VirtualizerHandle } from 'virtua';
import { VList } from 'virtua';
import { ThreadMessageItem } from './ThreadMessageItem';
import InfiniteListAnchor from '../../../../../components/InfiniteListAnchor';
import { useMergedRefsV2 } from '../../../../../hooks/useMergedRefsV2';
import { setMessageJumpQueryStringParameter } from '../../../../../lib/utils/setMessageJumpQueryStringParameter';
import { BubbleDate } from '../../../BubbleDate';
import { useKeepAtBottom } from '../../../MessageList/hooks/useKeepAtBottom';
import { useKeepMountedMessages } from '../../../MessageList/hooks/useKeepMountedMessages';
import { isMessageNewDay } from '../../../MessageList/lib/isMessageNewDay';
import MessageListProvider from '../../../MessageList/providers/MessageListProvider';
import { clearHighlightMessage, setHighlightMessage } from '../../../MessageList/providers/messageHighlightSubscription';
import LoadingMessagesIndicator from '../../../body/LoadingMessagesIndicator';
import { useRoom } from '../../../contexts/RoomContext';
import { useDateScroll } from '../../../hooks/useDateScroll';
import { useFirstUnreadMessageId } from '../../../hooks/useFirstUnreadMessageId';
import { useMessageListNavigation } from '../../../hooks/useMessageListNavigation';
import { useThreadMessagesQuery } from '../hooks/useThreadMessagesQuery';
import './threads.css';
const isMessageSequential = (current: IMessage, previous: IMessage | undefined, groupingRange: number): boolean => {
if (!previous) {
return false;
}
if (MessageTypes.isSystemMessage(current) || MessageTypes.isSystemMessage(previous)) {
return false;
}
if (current.groupable === false) {
return false;
}
if (current.u._id !== previous.u._id) {
return false;
}
if (current.alias !== previous.alias) {
return false;
}
return differenceInSeconds(current.ts, previous.ts) < groupingRange && !isMessageNewDay(current, previous);
};
export type ThreadMessageListProps = {
mainMessage: IThreadMainMessage;
shouldJumpToBottom: boolean;
setShouldJumpToBottom: (shouldJumpToBottom: boolean) => void;
};
const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBottom }: ThreadMessageListProps) => {
const { t } = useTranslation();
const msgJumpParam = useSearchParameter('msg');
const { bubbleRef, handleDateScroll, ...bubbleDate } = useDateScroll();
const {
data,
isLoading: loading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
fetchPreviousPage,
hasPreviousPage,
isFetchingPreviousPage,
loadMessageAround,
jumpToRecent,
} = useThreadMessagesQuery(mainMessage._id);
const messages = useMemo(() => data?.messages ?? [], [data?.messages]);
const userInteractedRef = useRef(false);
const isJumpingToMessageRef = useRef(false);
const isPrependRef = useRef(false);
const loadMoreMessages = useDebouncedCallback(
() => {
if (userInteractedRef.current && !isJumpingToMessageRef.current && hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
},
100,
[hasNextPage, isFetchingNextPage, fetchNextPage],
);
const initialScrollDoneRef = useRef(false);
const loadPreviousMessages = useDebouncedCallback(
() => {
if (
userInteractedRef.current &&
!isJumpingToMessageRef.current &&
initialScrollDoneRef.current &&
isAtBottom.current !== true &&
hasPreviousPage &&
!isFetchingPreviousPage
) {
isPrependRef.current = true;
void fetchPreviousPage();
}
},
100,
[hasPreviousPage, isFetchingPreviousPage, fetchPreviousPage],
);
const room = useRoom();
const uid = useUserId();
const hideUsernames = useUserPreference<boolean>('hideUsernames');
const showUserAvatar = !!useUserPreference<boolean>('displayAvatars');
const firstUnreadMessageId = useFirstUnreadMessageId();
const messageGroupingPeriod = useSetting('Message_GroupingPeriod', 300);
const { messageListRef } = useMessageListNavigation();
const virtualizerRef = useRef<VirtualizerHandle | null>(null);
const isAtBottom = useRef<boolean | null>(null);
const prevItemsLengthRef = useRef(0);
const { keepAtBottomRef, setKeepAtBottom } = useKeepAtBottom(isAtBottom);
const messagesLength = messages.length;
useEffect(() => {
setKeepAtBottom(() => {
if (virtualizerRef.current && !msgJumpParam && !isJumpingToMessageRef.current) {
virtualizerRef.current.scrollToIndex(messagesLength + 1, {
align: 'end',
});
}
});
}, [messagesLength, setKeepAtBottom, msgJumpParam]);
const loadingWindowKeyRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (loading || !msgJumpParam || isFetchingNextPage || isFetchingPreviousPage) {
return;
}
if (msgJumpParam === mainMessage._id && !hasPreviousPage) {
return;
}
if (messages.some((message) => message._id === msgJumpParam)) {
return;
}
const windowKey = `${mainMessage._id}:${msgJumpParam}`;
if (loadingWindowKeyRef.current === windowKey) {
return;
}
loadingWindowKeyRef.current = windowKey;
loadMessageAround(msgJumpParam).catch(() => {
if (loadingWindowKeyRef.current === windowKey) {
loadingWindowKeyRef.current = undefined;
}
isJumpingToMessageRef.current = false;
});
}, [loading, isFetchingNextPage, isFetchingPreviousPage, msgJumpParam, messages, mainMessage._id, loadMessageAround, hasPreviousPage]);
const interactionRef = useSafeRefCallback(
useCallback((element: HTMLDivElement) => {
const markInteracted = () => {
userInteractedRef.current = true;
};
const handleKeydown = (e: KeyboardEvent) => {
if (['PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) {
userInteractedRef.current = true;
}
};
const parent = element.parentElement;
parent?.addEventListener('pointerdown', markInteracted);
element.addEventListener('wheel', markInteracted, { passive: true });
element.addEventListener('keydown', handleKeydown);
return () => {
parent?.removeEventListener('pointerdown', markInteracted);
element.removeEventListener('wheel', markInteracted);
element.removeEventListener('keydown', handleKeydown);
};
}, []),
);
const mergedRefs = useMergedRefsV2(messageListRef, keepAtBottomRef, interactionRef);
const lastScrollSizeRef = useRef(0);
const showMainMessage = !hasPreviousPage;
const items = useMemo(() => {
if (loading) {
return [];
}
return showMainMessage ? [mainMessage, ...messages] : messages;
}, [loading, showMainMessage, mainMessage, messages]);
const prevItemsCountRef = useRef(0);
useLayoutEffect(() => {
if (isPrependRef.current && items.length !== prevItemsCountRef.current) {
isPrependRef.current = false;
}
prevItemsCountRef.current = items.length;
if (hasNextPage) {
isAtBottom.current = false;
}
});
const threadMsgTargetIndex = useMemo(() => {
if (!msgJumpParam || loading) {
return -1;
}
if (msgJumpParam === mainMessage._id) {
return showMainMessage ? 0 : -1;
}
const replyIndex = messages.findIndex((m) => m._id === msgJumpParam);
if (replyIndex < 0) {
return -1;
}
const mainMessageOffset = showMainMessage ? 1 : 0;
const previousPageLoaderOffset = hasPreviousPage ? 1 : 0;
return mainMessageOffset + previousPageLoaderOffset + replyIndex;
}, [msgJumpParam, loading, mainMessage._id, messages, showMainMessage, hasPreviousPage]);
const lastThreadJumpKeyRef = useRef<string | undefined>(undefined);
useEffect(() => {
lastThreadJumpKeyRef.current = undefined;
loadingWindowKeyRef.current = undefined;
prevItemsLengthRef.current = 0;
initialScrollDoneRef.current = false;
userInteractedRef.current = false;
isJumpingToMessageRef.current = false;
isPrependRef.current = false;
}, [mainMessage._id]);
useEffect(() => {
if (!msgJumpParam) {
isJumpingToMessageRef.current = false;
return;
}
isJumpingToMessageRef.current = true;
isAtBottom.current = false;
}, [msgJumpParam]);
useEffect(() => {
const handle = virtualizerRef.current;
if (!handle) return;
if (loading) return;
// `msg` deep link: jump effect runs below; do not force scroll to bottom
if (msgJumpParam) {
setShouldJumpToBottom(false);
return;
}
// Scroll to bottom when current user's optimistic (temp) message is appended.
// Fires before server confirmation, giving immediate scroll feedback.
const prev = prevItemsLengthRef.current;
prevItemsLengthRef.current = items.length;
if (items.length > prev && uid) {
const lastItem = items.at(-1);
if (lastItem?.temp && lastItem.u._id === uid && !hasNextPage) {
setShouldJumpToBottom(true);
}
}
if (isAtBottom.current === true && !isFetchingPreviousPage && !isFetchingNextPage && lastScrollSizeRef.current !== handle?.scrollSize) {
lastScrollSizeRef.current = handle?.scrollSize ?? 0;
setShouldJumpToBottom(true);
}
if (shouldJumpToBottom) {
// Optimistically mark as at-bottom before the scroll executes (rAF).
// This ensures the ResizeObserver in useKeepAtBottom re-scrolls if
// quote/attachment content grows between now and when Virtua fires the scroll.
isAtBottom.current = true;
handle.scrollToIndex(items.length, { align: 'end' });
setShouldJumpToBottom(false);
initialScrollDoneRef.current = true;
}
}, [
items,
loading,
msgJumpParam,
threadMsgTargetIndex,
shouldJumpToBottom,
setShouldJumpToBottom,
uid,
isFetchingPreviousPage,
isFetchingNextPage,
hasNextPage,
]);
useEffect(() => {
if (threadMsgTargetIndex < 0 || !msgJumpParam) {
return;
}
const jumpKey = `${mainMessage._id}:${msgJumpParam}`;
if (lastThreadJumpKeyRef.current === jumpKey) {
return;
}
const handle = virtualizerRef.current;
if (!handle) {
return;
}
lastThreadJumpKeyRef.current = jumpKey;
setShouldJumpToBottom(false);
initialScrollDoneRef.current = true;
let frame = 0;
let rafId = 0;
const recenter = () => {
handle.scrollToIndex(threadMsgTargetIndex, { align: 'center' });
frame += 1;
if (frame < 8) {
rafId = requestAnimationFrame(recenter);
}
};
recenter();
setHighlightMessage(msgJumpParam);
const highlightTimeout = setTimeout(() => {
clearHighlightMessage();
}, 2000);
const settleTimeout = setTimeout(() => {
isJumpingToMessageRef.current = false;
}, 500);
return () => {
cancelAnimationFrame(rafId);
clearTimeout(highlightTimeout);
clearTimeout(settleTimeout);
};
}, [threadMsgTargetIndex, msgJumpParam, mainMessage._id, setShouldJumpToBottom]);
useEffect(() => {
if (!msgJumpParam) {
return;
}
const clearMsgJumpParam = () => {
if (messages.find((m) => m._id === msgJumpParam) && mainMessage._id !== msgJumpParam) {
setMessageJumpQueryStringParameter(null);
}
};
setTimeout(() => {
clearMsgJumpParam();
}, 500);
}, [msgJumpParam, messages, mainMessage._id]);
useEffect(() => {
const handlerId = `thread-scroll-${mainMessage._id}`;
clientCallbacks.add(
'streamNewMessage',
(msg: IMessage) => {
if (msg.rid !== room._id || isEditedMessage(msg) || msg.tmid !== mainMessage._id) {
return;
}
if (msg.u._id === uid) {
if (hasNextPage) {
void jumpToRecent().then(() => setShouldJumpToBottom(true));
return;
}
setShouldJumpToBottom(true);
}
},
clientCallbacks.priority.MEDIUM,
handlerId,
);
return () => {
clientCallbacks.remove('streamNewMessage', handlerId);
};
}, [room._id, uid, mainMessage._id, setShouldJumpToBottom, hasNextPage, jumpToRecent]);
const keepMountedMessages = useKeepMountedMessages(items);
return (
<div className={['thread-list js-scroll-thread', hideUsernames && 'hide-usernames'].filter(isTruthy).join(' ')}>
<BubbleDate ref={bubbleRef} {...bubbleDate} />
<CustomVirtuaScrollbars ref={mergedRefs}>
<MessageListProvider>
<VList
ref={virtualizerRef}
shift={isPrependRef.current}
style={{ height: '100%' }}
aria-label={t('Thread_message_list')}
aria-busy={loading || isFetchingNextPage || isFetchingPreviousPage}
role='list'
keepMounted={keepMountedMessages}
onScroll={(offset) => {
const handle = virtualizerRef.current;
if (!handle) return;
if (offset < 200 && hasPreviousPage && !isJumpingToMessageRef.current) {
loadPreviousMessages();
}
// Copied from messageList, I'm unsure why this is necessary, but it seems to be needed to properly set the isAtBottom state
if (hasNextPage) {
isAtBottom.current = false;
} else {
if (handle.scrollSize >= handle.viewportSize) {
isAtBottom.current = true;
}
isAtBottom.current = offset - handle.scrollSize + handle.viewportSize >= -20;
}
if (hasNextPage && !isJumpingToMessageRef.current && offset - handle.scrollSize + handle.viewportSize >= -200) {
loadMoreMessages();
}
const topMessage = items[handle.findItemIndex(handle.scrollOffset)];
handleDateScroll(topMessage, offset);
}}
>
{loading ? (
<div className='load-more' role='presentation'>
<LoadingMessagesIndicator />
</div>
) : null}
{!loading && hasPreviousPage ? (
<div className='load-more' role='presentation'>
{isFetchingPreviousPage ? <LoadingMessagesIndicator /> : null}
</div>
) : null}
{!loading &&
items.map((message, index, { [index - 1]: previous }) => {
const sequential = isMessageSequential(message, previous, messageGroupingPeriod);
const newDay = isMessageNewDay(message, previous);
const shouldShowAsSequential = sequential && !newDay;
const firstUnread = firstUnreadMessageId === message._id;
const system = MessageTypes.isSystemMessage(message);
return (
<Fragment key={message._id}>
<ThreadMessageItem
message={message}
previous={previous}
sequential={sequential}
shouldShowAsSequential={shouldShowAsSequential}
showUserAvatar={showUserAvatar}
firstUnread={firstUnread}
system={system}
/>
</Fragment>
);
})}
{!loading && hasNextPage ? (
<div className='load-more' role='presentation'>
{isFetchingNextPage ? <LoadingMessagesIndicator /> : <InfiniteListAnchor loadMore={loadMoreMessages} />}
</div>
) : null}
</VList>
</MessageListProvider>
</CustomVirtuaScrollbars>
</div>
);
};
export default ThreadMessageList;

View File

@@ -0,0 +1,16 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
const ThreadSkeleton = () => {
return (
<Box padding={24}>
<Skeleton width='32px' height='32px' variant='rect' /> <Skeleton />
{Array(5)
.fill(5)
.map((_, index) => (
<Skeleton key={index} />
))}
</Box>
);
};
export default ThreadSkeleton;

View File

@@ -0,0 +1,18 @@
import type { IThreadMainMessage } from '@rocket.chat/core-typings';
import { ContextualbarTitle } from '@rocket.chat/ui-client';
import { useMemo } from 'react';
import { useNormalizedThreadTitleHtml } from '../hooks/useNormalizedThreadTitleHtml';
export type ThreadTitleProps = {
mainMessage: IThreadMainMessage;
};
const ThreadTitle = ({ mainMessage }: ThreadTitleProps) => {
const html = useNormalizedThreadTitleHtml(mainMessage);
const innerHTML = useMemo(() => ({ __html: html }), [html]);
return <ContextualbarTitle dangerouslySetInnerHTML={innerHTML} />;
};
export default ThreadTitle;

View File

@@ -0,0 +1,6 @@
.thread-list {
overflow: hidden auto;
word-wrap: break-word;
flex-grow: 1;
flex-shrink: 1;
}