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,164 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import RoomMessage from './RoomMessage';
import { MessageListContext, messageListContextDefaultValue } from '../list/MessageListContext';
const message: IMessage = {
ts: new Date('2021-10-27T00:00:00.000Z'),
u: {
_id: 'userId',
name: 'userName',
username: 'userName',
},
msg: 'message body',
md: [
{
type: 'PARAGRAPH',
value: [
{
type: 'PLAIN_TEXT',
value: 'message body',
},
],
},
],
rid: 'roomId',
_id: 'messageId',
_updatedAt: new Date('2021-10-27T00:00:00.000Z'),
urls: [],
};
jest.mock('../header/hooks/useMessageRoles', () => ({
useMessageRoles: () => [],
}));
jest.mock('../../../lib/utils/fireGlobalEvent', () => ({ fireGlobalEvent: () => undefined }));
jest.mock('../../../views/room/hooks/useGoToRoom', () => ({ useGoToRoom: () => undefined }));
jest.mock('../../../views/room/contextualBar/Threads/hooks/useGetMessageByID', () => undefined);
jest.mock('../../../views/room/MessageList/hooks/useAutoTranslate', () => ({
useAutoTranslate: () => ({
autoTranslateEnabled: false,
autoTranslateLanguage: '',
showAutoTranslate: () => false,
}),
}));
jest.mock('../../../lib/actionLinks', () => undefined);
jest.mock('../../../lib/RoomManager', () => ({
RoomManager: { getStore: jest.fn() },
useOpenedRoom: () => undefined,
getCollapsibleEventKey: (key: string) => `collapsibleToggled-${key}`,
}));
it('should show normal message', () => {
render(
<RoomMessage
message={message}
sequential={false}
all={false}
mention={false}
unread={false}
ignoredUser={false}
showUserAvatar={true}
/>,
{
wrapper: mockAppRoot().build(),
},
);
expect(screen.getByRole('figure')).toBeInTheDocument();
expect(screen.getByText('message body')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Message_Ignored' })).not.toBeInTheDocument();
});
it('should show fallback content for ignored user', () => {
render(
<RoomMessage
message={message}
sequential={false}
all={false}
mention={false}
unread={false}
ignoredUser={true}
showUserAvatar={true}
/>,
{
wrapper: mockAppRoot().build(),
},
);
expect(screen.getByRole('figure')).toBeInTheDocument();
expect(screen.queryByText('message body')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Message_Ignored' })).toBeInTheDocument();
});
it('should show ignored message', () => {
render(
<RoomMessage
message={{ ...message, ignored: true }}
sequential={false}
all={false}
mention={false}
unread={false}
ignoredUser={false}
showUserAvatar={true}
/>,
{
wrapper: mockAppRoot().build(),
},
);
expect(screen.getByRole('figure')).toBeInTheDocument();
expect(screen.queryByText('message body')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Message_Ignored' })).toBeInTheDocument();
});
it('should show read receipt', () => {
render(
<RoomMessage
message={message}
sequential={false}
all={false}
mention={false}
unread={false}
ignoredUser={false}
showUserAvatar={true}
/>,
{
wrapper: mockAppRoot()
.wrap((children) => (
<MessageListContext.Provider value={{ ...messageListContextDefaultValue, readReceipts: { enabled: true, storeUsers: false } }}>
{children}
</MessageListContext.Provider>
))
.build(),
},
);
expect(screen.getByRole('status', { name: 'Message_viewed' })).toBeInTheDocument();
});
it('should not show read receipt if receipt is disabled', () => {
render(
<RoomMessage
message={message}
sequential={false}
all={false}
mention={false}
unread={false}
ignoredUser={false}
showUserAvatar={true}
/>,
{
wrapper: mockAppRoot()
.wrap((children) => (
<MessageListContext.Provider value={{ ...messageListContextDefaultValue, readReceipts: { enabled: false, storeUsers: false } }}>
{children}
</MessageListContext.Provider>
))
.build(),
},
);
expect(screen.queryByRole('status', { name: 'Message_viewed' })).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,152 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Message, MessageLeftContainer, MessageContainer, CheckBox } from '@rocket.chat/fuselage';
import { useToggle } from '@rocket.chat/fuselage-hooks';
import { MessageAvatar } from '@rocket.chat/ui-avatar';
import { useUserId, useUserCard } from '@rocket.chat/ui-contexts';
import type { ComponentProps, KeyboardEvent } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { MessageActionContext } from '../../../lib/MessageAction';
import { useIsMessageHighlight } from '../../../views/room/MessageList/contexts/MessageHighlightContext';
import {
useIsSelecting,
useToggleSelect,
useIsSelectedMessage,
useCountSelected,
} from '../../../views/room/MessageList/contexts/SelectedMessagesContext';
import Emoji from '../../Emoji';
import IgnoredContent from '../IgnoredContent';
import MessageHeader from '../MessageHeader';
import MessageToolbarHolder from '../MessageToolbarHolder';
import StatusIndicators from '../StatusIndicators';
import RoomMessageContent from './room/RoomMessageContent';
import { getCheckboxLabel } from '../helpers/getCheckboxLabel';
import { useMessageListReadReceipts } from '../list/MessageListContext';
export type RoomMessageProps = {
message: IMessage & { ignored?: boolean };
showUserAvatar: boolean;
sequential: boolean;
unread: boolean;
mention: boolean;
all: boolean;
context?: MessageActionContext;
ignoredUser?: boolean;
searchText?: string;
} & ComponentProps<typeof Message>;
const getAriaLabelledBy = ({
readReceiptEnabled,
messageId,
sequential,
}: {
readReceiptEnabled: boolean;
messageId: string;
sequential: boolean;
}) => {
const labels: string[] = [];
if (!sequential) {
labels.push(`${messageId}-displayName`, `${messageId}-time`);
}
labels.push(`${messageId}-content`);
if (readReceiptEnabled) {
labels.push(`${messageId}-read-status`);
}
return labels.join(' ');
};
const RoomMessage = ({
message,
showUserAvatar,
sequential,
all,
mention,
unread,
context,
ignoredUser,
searchText,
...props
}: RoomMessageProps) => {
const { t } = useTranslation();
const uid = useUserId();
const editing = useIsMessageHighlight(message._id);
const [displayIgnoredMessage, toggleDisplayIgnoredMessage] = useToggle(false);
const ignored = (ignoredUser || message.ignored) && !displayIgnoredMessage;
const { openUserCard, triggerProps } = useUserCard();
const selecting = useIsSelecting();
const toggleSelected = useToggleSelect(message._id);
const selected = useIsSelectedMessage(message._id);
const { enabled: readReceiptEnabled } = useMessageListReadReceipts();
useCountSelected();
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!selecting) return;
if (!(e.code === 'Space' || e.code === 'Enter')) return;
e.preventDefault();
toggleSelected();
};
const checkboxLabel = getCheckboxLabel(message, t);
return (
<Message
id={message._id}
role='listitem'
tabIndex={0}
aria-roledescription={t('message')}
aria-labelledby={getAriaLabelledBy({ readReceiptEnabled, messageId: message._id, sequential })}
onClick={selecting ? toggleSelected : undefined}
onKeyDown={handleKeyDown}
isSelected={selected}
isEditing={editing}
isPending={message.temp}
sequential={sequential}
data-id={message._id}
data-mid={message._id}
data-unread={unread}
data-sequential={sequential}
data-own={message.u._id === uid}
aria-busy={message.temp}
{...props}
>
<MessageLeftContainer>
{!sequential && message.u.username && !selecting && showUserAvatar && (
<MessageAvatar
emoji={message.emoji ? <Emoji emojiHandle={message.emoji} fillContainer /> : undefined}
avatarUrl={message.avatar}
username={message.u.username}
size='x36'
onClick={(e) => openUserCard(e, message.u.username)}
style={{ cursor: 'pointer' }}
role='button'
{...triggerProps}
/>
)}
{selecting && <CheckBox checked={selected} onChange={toggleSelected} aria-label={checkboxLabel} />}
{sequential && <StatusIndicators message={message} />}
</MessageLeftContainer>
<MessageContainer>
{!sequential && <MessageHeader message={message} />}
{ignored ? (
<IgnoredContent messageId={message._id} onShowMessageIgnored={toggleDisplayIgnoredMessage} />
) : (
<RoomMessageContent message={message} unread={unread} mention={mention} all={all} searchText={searchText} />
)}
</MessageContainer>
{!message.private && message?.e2e !== 'pending' && !selecting && <MessageToolbarHolder message={message} context={context} />}
</Message>
);
};
export default memo(RoomMessage);

View File

@@ -0,0 +1,73 @@
import { faker } from '@faker-js/faker';
import type { IMessage } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { Random } from '@rocket.chat/random';
import { render, screen } from '@testing-library/react';
import SystemMessage from './SystemMessage';
jest.mock('../content/Attachments', () => ({
default: () => <div>attachments</div>,
}));
jest.mock('../content/MessageActions', () => ({
default: () => <div>message actions</div>,
}));
jest.mock('meteor/meteor', () => {
const actual = jest.requireActual('meteor/meteor');
return {
...actual,
Meteor: {
...actual.Meteor,
startup: (callback: () => void) => callback(),
},
};
});
const wrapper = mockAppRoot().withTranslations('en', 'core', {
changed_room_description_to__room_description_: 'changed room description to: {{room_description}}',
});
const createBaseMessage = (msg: string): IMessage => ({
_id: Random.id(),
t: 'room_changed_description',
rid: Random.id(),
ts: new Date(),
msg,
u: {
_id: Random.id(),
username: faker.person.firstName().toLocaleLowerCase(),
name: faker.person.fullName(),
},
groupable: false,
_updatedAt: new Date(),
});
describe('SystemMessage', () => {
it('should render system message', () => {
const message = createBaseMessage('& test &');
render(<SystemMessage message={message} showUserAvatar />, { wrapper: wrapper.build() });
expect(screen.getByText('changed room description to: & test &')).toBeInTheDocument();
});
it('should not show escaped html while rendering system message', () => {
const message = createBaseMessage('& test &');
render(<SystemMessage message={message} showUserAvatar />, { wrapper: wrapper.build() });
expect(screen.getByText('changed room description to: & test &')).toBeInTheDocument();
expect(screen.queryByText('changed room description to: &amp; test &amp;')).not.toBeInTheDocument();
});
it('should not inject html', () => {
const message = createBaseMessage('<button title="test-title">OK</button>');
render(<SystemMessage message={message} showUserAvatar />, { wrapper: wrapper.build() });
expect(screen.queryByTitle('test-title')).not.toBeInTheDocument();
expect(screen.getByText('changed room description to: <button title="test-title">OK</button>')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,131 @@
import type { IMessage } from '@rocket.chat/core-typings';
import {
MessageSystem,
MessageSystemBody,
MessageSystemContainer,
MessageSystemLeftContainer,
MessageSystemName,
MessageSystemTimestamp,
MessageSystemBlock,
CheckBox,
MessageUsername,
MessageNameContainer,
} from '@rocket.chat/fuselage';
import { useButtonPattern } from '@rocket.chat/fuselage-hooks';
import { MessageTypes } from '@rocket.chat/message-types';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { useUserDisplayName } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useUserPresence, useUserCard } from '@rocket.chat/ui-contexts';
import type { ComponentProps, KeyboardEvent } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { normalizeUsername } from '../../../../lib/utils/normalizeUsername';
import {
useIsSelecting,
useToggleSelect,
useIsSelectedMessage,
useCountSelected,
} from '../../../views/room/MessageList/contexts/SelectedMessagesContext';
import Attachments from '../content/Attachments';
import MessageActions from '../content/MessageActions';
import { getCheckboxLabel } from '../helpers/getCheckboxLabel';
import {
useMessageListShowRealName,
useMessageListShowUsername,
useMessageListFormatDateAndTime,
useMessageListFormatTime,
} from '../list/MessageListContext';
export type SystemMessageProps = {
message: IMessage;
showUserAvatar: boolean;
} & ComponentProps<typeof MessageSystem>;
const SystemMessage = ({ message, showUserAvatar, ...props }: SystemMessageProps) => {
const { t } = useTranslation();
const formatTime = useMessageListFormatTime();
const formatDateAndTime = useMessageListFormatDateAndTime();
const { triggerProps, openUserCard } = useUserCard();
const showRealName = useMessageListShowRealName();
const user = { ...message.u, roles: [], ...useUserPresence(message.u._id) };
const normalizedUsername = normalizeUsername(user.username);
const usernameAndRealNameAreSame = !user.name || normalizedUsername === user.name;
const showUsername = useMessageListShowUsername() && showRealName && !usernameAndRealNameAreSame;
const displayName = useUserDisplayName(user);
const messageType = MessageTypes.getType(message);
const isSelecting = useIsSelecting();
const toggleSelected = useToggleSelect(message._id);
const isSelected = useIsSelectedMessage(message._id);
useCountSelected();
const buttonProps = useButtonPattern((e) => openUserCard(e, user.username));
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!isSelecting) return;
if (!(e.code === 'Space' || e.code === 'Enter')) return;
e.preventDefault();
toggleSelected();
};
const checkboxLabel = getCheckboxLabel(message, t);
return (
<MessageSystem
role='listitem'
aria-roledescription={t('system_message')}
tabIndex={0}
onClick={isSelecting ? toggleSelected : undefined}
onKeyDown={handleKeyDown}
isSelected={isSelected}
data-system-message-type={message.t}
{...props}
>
<MessageSystemLeftContainer>
{!isSelecting && showUserAvatar && <UserAvatar username={message.u.username} size='x18' />}
{isSelecting && <CheckBox checked={isSelected} onChange={toggleSelected} aria-label={checkboxLabel} />}
</MessageSystemLeftContainer>
<MessageSystemContainer>
<MessageSystemBlock>
<MessageNameContainer style={{ cursor: 'pointer' }} {...buttonProps} {...triggerProps}>
<MessageSystemName>{displayName}</MessageSystemName>
{showUsername && (
<>
{' '}
<MessageUsername data-username={normalizedUsername}>@{normalizedUsername}</MessageUsername>
</>
)}
</MessageNameContainer>
{messageType && (
<MessageSystemBody role='document' aria-roledescription={t('system_message_body')}>
{messageType.text(t, message)}
</MessageSystemBody>
)}
<MessageSystemTimestamp title={formatDateAndTime(message.ts)}>{formatTime(message.ts)}</MessageSystemTimestamp>
</MessageSystemBlock>
{message.attachments && (
<MessageSystemBlock>
<Attachments attachments={message.attachments} />
</MessageSystemBlock>
)}
{message.actionLinks?.length && (
<MessageActions
message={message}
actions={message.actionLinks.map(({ method_id: methodId, i18nLabel, ...action }) => ({
methodId,
i18nLabel: i18nLabel as TranslationKey,
...action,
}))}
/>
)}
</MessageSystemContainer>
</MessageSystem>
);
};
export default memo(SystemMessage);

View File

@@ -0,0 +1,77 @@
import type { IThreadMessage } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ThreadMessage from './ThreadMessage';
const message: IThreadMessage = {
ts: new Date('2021-10-27T00:00:00.000Z'),
u: {
_id: 'userId',
name: 'userName',
username: 'userName',
},
msg: 'message body',
md: [
{
type: 'PARAGRAPH',
value: [
{
type: 'PLAIN_TEXT',
value: 'message body',
},
],
},
],
rid: 'roomId',
tmid: 'threadId',
_id: 'messageId',
_updatedAt: new Date('2021-10-27T00:00:00.000Z'),
urls: [],
};
it('should show normal message', () => {
render(<ThreadMessage message={message} sequential={false} unread={false} ignoredUser={false} showUserAvatar={true} />, {
wrapper: mockAppRoot().build(),
});
expect(screen.getByText('message body')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Message_Ignored' })).not.toBeInTheDocument();
});
it('should show fallback content for ignored user', () => {
render(<ThreadMessage message={message} sequential={false} unread={false} ignoredUser={true} showUserAvatar={true} />, {
wrapper: mockAppRoot().build(),
});
expect(screen.queryByText('message body')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Message_Ignored' })).toBeInTheDocument();
});
it('should show the message body once the ignored placeholder is clicked', async () => {
render(<ThreadMessage message={message} sequential={false} unread={false} ignoredUser={true} showUserAvatar={true} />, {
wrapper: mockAppRoot().build(),
});
await userEvent.click(screen.getByRole('button', { name: 'Message_Ignored' }));
expect(screen.getByText('message body')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Message_Ignored' })).not.toBeInTheDocument();
});
it('should hide the message body when the user gets ignored without remounting', () => {
const { rerender } = render(
<ThreadMessage message={message} sequential={false} unread={false} ignoredUser={false} showUserAvatar={true} />,
{
wrapper: mockAppRoot().build(),
},
);
expect(screen.getByText('message body')).toBeInTheDocument();
rerender(<ThreadMessage message={message} sequential={false} unread={false} ignoredUser={true} showUserAvatar={true} />);
expect(screen.queryByText('message body')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Message_Ignored' })).toBeInTheDocument();
});

View File

@@ -0,0 +1,81 @@
import { type IThreadMessage, type IThreadMainMessage, isVideoConfMessage } from '@rocket.chat/core-typings';
import { Message, MessageLeftContainer, MessageContainer } from '@rocket.chat/fuselage';
import { useToggle } from '@rocket.chat/fuselage-hooks';
import { MessageAvatar } from '@rocket.chat/ui-avatar';
import { useTranslation, useUserId, useUserCard } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
import type { MessageActionContext } from '../../../lib/MessageAction';
import { useIsMessageHighlight } from '../../../views/room/MessageList/contexts/MessageHighlightContext';
import Emoji from '../../Emoji';
import IgnoredContent from '../IgnoredContent';
import MessageHeader from '../MessageHeader';
import MessageToolbarHolder from '../MessageToolbarHolder';
import StatusIndicators from '../StatusIndicators';
import ThreadMessageContent from './thread/ThreadMessageContent';
export type ThreadMessageProps = {
message: IThreadMessage | IThreadMainMessage;
unread: boolean;
sequential: boolean;
showUserAvatar: boolean;
ignoredUser?: boolean;
};
const ThreadMessage = ({ message, sequential, unread, showUserAvatar, ignoredUser }: ThreadMessageProps) => {
const t = useTranslation();
const uid = useUserId();
const editing = useIsMessageHighlight(message._id);
const [displayIgnoredMessage, toggleDisplayIgnoredMessage] = useToggle(false);
const ignored = ignoredUser && !displayIgnoredMessage;
const { openUserCard, triggerProps } = useUserCard();
// Checks if is videoconf message to limit toolbox actions
const messageContext: MessageActionContext = isVideoConfMessage(message) ? 'videoconf-threads' : 'threads';
return (
<Message
role='listitem'
aria-roledescription={t('thread_message')}
tabIndex={0}
id={message._id}
isEditing={editing}
isPending={message.temp}
sequential={sequential}
data-id={message._id}
data-mid={message._id}
data-unread={unread}
data-sequential={sequential}
data-own={message.u._id === uid}
>
<MessageLeftContainer>
{!sequential && message.u.username && showUserAvatar && (
<MessageAvatar
emoji={message.emoji ? <Emoji emojiHandle={message.emoji} fillContainer /> : undefined}
avatarUrl={message.avatar}
username={message.u.username}
size='x36'
onClick={(e) => openUserCard(e, message.u.username)}
style={{ cursor: 'pointer' }}
role='button'
{...triggerProps}
/>
)}
{sequential && <StatusIndicators message={message} />}
</MessageLeftContainer>
<MessageContainer>
{!sequential && <MessageHeader message={message} />}
{ignored ? (
<IgnoredContent messageId={message._id} onShowMessageIgnored={toggleDisplayIgnoredMessage} />
) : (
<ThreadMessageContent message={message} />
)}
</MessageContainer>
{!message.private && <MessageToolbarHolder message={message} context={messageContext} />}
</Message>
);
};
export default memo(ThreadMessage);

View File

@@ -0,0 +1,147 @@
import type { IThreadMessage } from '@rocket.chat/core-typings';
import {
Skeleton,
ThreadMessage,
ThreadMessageRow,
ThreadMessageLeftContainer,
ThreadMessageIconThread,
ThreadMessageContainer,
ThreadMessageOrigin,
ThreadMessageBody,
ThreadMessageUnfollow,
CheckBox,
MessageStatusIndicatorItem,
} from '@rocket.chat/fuselage';
import { MessageTypes } from '@rocket.chat/message-types';
import { MessageAvatar } from '@rocket.chat/ui-avatar';
import type { ComponentProps } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import {
useIsSelecting,
useToggleSelect,
useIsSelectedMessage,
useCountSelected,
} from '../../../views/room/MessageList/contexts/SelectedMessagesContext';
import { useMessageBody } from '../../../views/room/MessageList/hooks/useMessageBody';
import { useParentMessage } from '../../../views/room/MessageList/hooks/useParentMessage';
import { isParsedMessage } from '../../../views/room/MessageList/lib/isParsedMessage';
import { useGoToThread } from '../../../views/room/hooks/useGoToThread';
import Emoji from '../../Emoji';
import { useShowTranslated } from '../list/MessageListContext';
import ThreadMessagePreviewBody from './threadPreview/ThreadMessagePreviewBody';
import { getCheckboxLabel } from '../helpers/getCheckboxLabel';
export type ThreadMessagePreviewProps = {
message: IThreadMessage;
showUserAvatar: boolean;
sequential: boolean;
} & ComponentProps<typeof ThreadMessage>;
const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }: ThreadMessagePreviewProps) => {
const parentMessage = useParentMessage(message.tmid);
const translated = useShowTranslated(message);
const { t } = useTranslation();
const isSelecting = useIsSelecting();
const toggleSelected = useToggleSelect(message._id);
const isSelected = useIsSelectedMessage(message._id);
useCountSelected();
const messageType = parentMessage.isSuccess ? MessageTypes.getType(parentMessage.data) : null;
const messageBody = useMessageBody(parentMessage.data);
const previewMessage = isParsedMessage(messageBody) ? { md: messageBody } : { msg: messageBody };
const goToThread = useGoToThread();
const handleThreadClick = () => {
if (!isSelecting) {
if (!sequential) {
return parentMessage.isSuccess && goToThread({ rid: message.rid, tmid: message.tmid, msg: parentMessage.data?._id });
}
return goToThread({ rid: message.rid, tmid: message.tmid, msg: message._id });
}
return toggleSelected();
};
const checkboxLabel = getCheckboxLabel(message, t);
return (
<ThreadMessage
role='link'
aria-roledescription={t('thread_message_preview')}
tabIndex={0}
onClick={handleThreadClick}
onKeyDown={(e) => (e.code === 'Enter' || e.code === 'Space') && handleThreadClick()}
isSelected={isSelected}
{...props}
>
{!sequential && (
<ThreadMessageRow>
<ThreadMessageLeftContainer>
<ThreadMessageIconThread />
</ThreadMessageLeftContainer>
<ThreadMessageContainer>
<ThreadMessageOrigin system={!!messageType}>
{parentMessage.isSuccess && !messageType && (
<>
{(parentMessage.data as { ignored?: boolean })?.ignored ? (
t('Message_Ignored')
) : (
<ThreadMessagePreviewBody message={{ ...parentMessage.data, ...previewMessage }} />
)}
{translated && (
<>
{' '}
<MessageStatusIndicatorItem name='language' color='info' title={t('Translated')} />
</>
)}
</>
)}
{messageType?.text(t, message)}
{parentMessage.isLoading && <Skeleton />}
</ThreadMessageOrigin>
<ThreadMessageUnfollow />
</ThreadMessageContainer>
</ThreadMessageRow>
)}
<ThreadMessageRow>
<ThreadMessageLeftContainer>
{!isSelecting && showUserAvatar && (
<MessageAvatar
emoji={message.emoji ? <Emoji emojiHandle={message.emoji} fillContainer /> : undefined}
username={message.u.username}
size='x18'
/>
)}
{isSelecting && <CheckBox checked={isSelected} onChange={toggleSelected} aria-label={checkboxLabel} />}
</ThreadMessageLeftContainer>
<ThreadMessageContainer>
<ThreadMessageBody>
{(message as { ignored?: boolean }).ignored ? (
t('Message_Ignored')
) : (
<>
<ThreadMessagePreviewBody message={message} />
{translated && (
<>
{' '}
<MessageStatusIndicatorItem name='language' title={t('Translated')} />
</>
)}
</>
)}
</ThreadMessageBody>
</ThreadMessageContainer>
</ThreadMessageRow>
</ThreadMessage>
);
};
export default memo(ThreadMessagePreview);

View File

@@ -0,0 +1,143 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { isDiscussionMessage, isThreadMainMessage, isE2EEMessage, isQuoteAttachment } from '@rocket.chat/core-typings';
import { MessageBody } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useUserId, useUserPresence } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useChat } from '../../../../views/room/contexts/ChatContext';
import MessageContentBody from '../../MessageContentBody';
import ReadReceiptIndicator from '../../ReadReceiptIndicator';
import Attachments from '../../content/Attachments';
import BroadcastMetrics from '../../content/BroadcastMetrics';
import DiscussionMetrics from '../../content/DiscussionMetrics';
import Location from '../../content/Location';
import MessageActions from '../../content/MessageActions';
import Reactions from '../../content/Reactions';
import ThreadMetrics from '../../content/ThreadMetrics';
import UrlPreviews from '../../content/UrlPreviews';
import { useNormalizedMessage } from '../../hooks/useNormalizedMessage';
import { useOembedLayout } from '../../hooks/useOembedLayout';
import { useSubscriptionFromMessageQuery } from '../../hooks/useSubscriptionFromMessageQuery';
import { useMessageListReadReceipts } from '../../list/MessageListContext';
import UiKitMessageBlock from '../../uikit/UiKitMessageBlock';
export type RoomMessageContentProps = {
message: IMessage;
unread: boolean;
mention: boolean;
all: boolean;
searchText?: string;
};
const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomMessageContentProps) => {
const encrypted = isE2EEMessage(message);
const { enabled: oembedEnabled } = useOembedLayout();
const subscription = useSubscriptionFromMessageQuery(message).data ?? undefined;
const broadcast = subscription?.broadcast ?? false;
const uid = useUserId();
const { enabled: readReceiptEnabled } = useMessageListReadReceipts();
const messageUser = { ...message.u, roles: [], ...useUserPresence(message.u._id) };
const chat = useChat();
const { t } = useTranslation();
const normalizedMessage = useNormalizedMessage(message);
const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending';
const quotes = normalizedMessage?.attachments?.filter(isQuoteAttachment) || [];
const attachments = normalizedMessage?.attachments?.filter((attachment) => !isQuoteAttachment(attachment)) || [];
return (
<>
{isMessageEncrypted && (
<MessageBody role='document' aria-roledescription={t('message_body')}>
{t('E2E_message_encrypted_placeholder')}
</MessageBody>
)}
{!!quotes?.length && (
<Attachments
attachments={quotes}
source={{ rid: message.rid, mid: message._id, username: message.u.username, name: message.u.name }}
/>
)}
{!normalizedMessage.blocks?.length && !!normalizedMessage.md?.length && (
<>
{(!encrypted || normalizedMessage.e2e === 'done') && (
<MessageContentBody
id={`${normalizedMessage._id}-content`}
md={normalizedMessage.md}
msg={normalizedMessage.mdSource}
mentions={normalizedMessage.mentions}
channels={normalizedMessage.channels}
searchText={searchText}
/>
)}
</>
)}
{!!attachments && (
<Attachments
id={message.files?.[0]?._id}
attachments={attachments}
source={{ rid: message.rid, mid: message._id, username: message.u.username, name: message.u.name }}
/>
)}
{normalizedMessage.blocks && (
<UiKitMessageBlock rid={normalizedMessage.rid} mid={normalizedMessage._id} blocks={normalizedMessage.blocks} />
)}
{oembedEnabled && !!normalizedMessage.urls?.length && <UrlPreviews urls={normalizedMessage.urls} />}
{normalizedMessage.actionLinks?.length && (
<MessageActions
message={normalizedMessage}
actions={normalizedMessage.actionLinks.map(({ method_id: methodId, i18nLabel, ...action }) => ({
methodId,
i18nLabel: i18nLabel as TranslationKey,
...action,
}))}
/>
)}
{normalizedMessage.reactions && Object.keys(normalizedMessage.reactions).length && <Reactions message={normalizedMessage} />}
{chat && isThreadMainMessage(normalizedMessage) && (
<ThreadMetrics
counter={normalizedMessage.tcount}
following={Boolean(uid && normalizedMessage?.replies?.indexOf(uid) > -1)}
mid={normalizedMessage._id}
rid={normalizedMessage.rid}
lm={normalizedMessage.tlm}
unread={unread}
mention={mention}
all={all}
participants={normalizedMessage?.replies}
/>
)}
{isDiscussionMessage(normalizedMessage) && (
<DiscussionMetrics
count={normalizedMessage.dcount}
drid={normalizedMessage.drid}
lm={normalizedMessage.dlm}
rid={normalizedMessage.rid}
/>
)}
{normalizedMessage.location && <Location location={normalizedMessage.location} />}
{broadcast && !!messageUser.username && normalizedMessage.u._id !== uid && (
<BroadcastMetrics username={messageUser.username} message={normalizedMessage} />
)}
{readReceiptEnabled && <ReadReceiptIndicator mid={normalizedMessage._id} unread={normalizedMessage.unread} />}
</>
);
};
export default memo(RoomMessageContent);

View File

@@ -0,0 +1,112 @@
import type { IThreadMainMessage, IThreadMessage } from '@rocket.chat/core-typings';
import { isE2EEMessage, isQuoteAttachment } from '@rocket.chat/core-typings';
import { MessageBody } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useUserId, useUserPresence } from '@rocket.chat/ui-contexts';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import MessageContentBody from '../../MessageContentBody';
import ReadReceiptIndicator from '../../ReadReceiptIndicator';
import Attachments from '../../content/Attachments';
import BroadcastMetrics from '../../content/BroadcastMetrics';
import Location from '../../content/Location';
import MessageActions from '../../content/MessageActions';
import Reactions from '../../content/Reactions';
import UrlPreviews from '../../content/UrlPreviews';
import { useNormalizedMessage } from '../../hooks/useNormalizedMessage';
import { useOembedLayout } from '../../hooks/useOembedLayout';
import { useSubscriptionFromMessageQuery } from '../../hooks/useSubscriptionFromMessageQuery';
import { useMessageListReadReceipts } from '../../list/MessageListContext';
import UiKitMessageBlock from '../../uikit/UiKitMessageBlock';
export type ThreadMessageContentProps = {
message: IThreadMessage | IThreadMainMessage;
};
const ThreadMessageContent = ({ message }: ThreadMessageContentProps) => {
const encrypted = isE2EEMessage(message);
const { enabled: oembedEnabled } = useOembedLayout();
const subscription = useSubscriptionFromMessageQuery(message).data ?? undefined;
const broadcast = subscription?.broadcast ?? false;
const uid = useUserId();
const { enabled: readReceiptEnabled } = useMessageListReadReceipts();
const messageUser = { ...message.u, roles: [], ...useUserPresence(message.u._id) };
const { t } = useTranslation();
const normalizedMessage = useNormalizedMessage(message);
const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending';
const quotes = normalizedMessage?.attachments?.filter(isQuoteAttachment) || [];
const attachments = normalizedMessage?.attachments?.filter((attachment) => !isQuoteAttachment(attachment)) || [];
return (
<>
{isMessageEncrypted && (
<MessageBody role='document' aria-roledescription={t('message_body')}>
{t('E2E_message_encrypted_placeholder')}
</MessageBody>
)}
{!!quotes?.length && (
<Attachments
attachments={quotes}
source={{ rid: message.rid, mid: message._id, username: message.u.username, name: message.u.name }}
/>
)}
{!normalizedMessage.blocks?.length && !!normalizedMessage.md?.length && (
<>
{(!encrypted || normalizedMessage.e2e === 'done') && (
<MessageContentBody
md={normalizedMessage.md}
msg={normalizedMessage.mdSource}
mentions={normalizedMessage.mentions}
channels={normalizedMessage.channels}
/>
)}
</>
)}
{normalizedMessage.blocks && (
<UiKitMessageBlock rid={normalizedMessage.rid} mid={normalizedMessage._id} blocks={normalizedMessage.blocks} />
)}
{!!attachments && (
<Attachments
id={message.files?.[0]?._id}
attachments={attachments}
source={{ rid: message.rid, mid: message._id, username: message.u.username, name: message.u.name }}
/>
)}
{oembedEnabled && !!normalizedMessage.urls?.length && <UrlPreviews urls={normalizedMessage.urls} />}
{normalizedMessage.actionLinks?.length && (
<MessageActions
message={normalizedMessage}
actions={normalizedMessage.actionLinks.map(({ method_id: methodId, i18nLabel, ...action }) => ({
methodId,
i18nLabel: i18nLabel as TranslationKey,
...action,
}))}
/>
)}
{normalizedMessage.reactions && Object.keys(normalizedMessage.reactions).length && <Reactions message={normalizedMessage} />}
{normalizedMessage.location && <Location location={normalizedMessage.location} />}
{broadcast && !!messageUser.username && normalizedMessage.u._id !== uid && (
<BroadcastMetrics username={messageUser.username} message={normalizedMessage} />
)}
{readReceiptEnabled && <ReadReceiptIndicator mid={normalizedMessage._id} unread={normalizedMessage.unread} />}
</>
);
};
export default memo(ThreadMessageContent);

View File

@@ -0,0 +1,87 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import * as stories from './ThreadMessagePreviewBody.stories';
const { Default } = composeStories(stories);
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
jest.mock('../../../../lib/utils/fireGlobalEvent', () => ({
fireGlobalEvent: jest.fn(),
}));
jest.mock('../../../../views/room/hooks/useGoToRoom', () => ({
useGoToRoom: jest.fn(),
}));
test.each(testCases)(`renders ThreadMessagePreviewBody without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(view.baseElement).toMatchSnapshot();
});
test.each(testCases)('ThreadMessagePreviewBody should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should not show an empty thread preview', async () => {
const { container } = render(
<Default
message={{
_id: 'message-id',
ts: new Date(),
msg: 'http://localhost:3000/group/ds?msg=ZoX9pDowqNb4BiWxf',
md: [
{
type: 'PARAGRAPH',
value: [
{
type: 'PLAIN_TEXT',
value: 'message attachment text quote',
},
],
},
],
attachments: [
{
text: 'message attachment text quote',
md: [
{
type: 'PARAGRAPH',
value: [
{
type: 'PLAIN_TEXT',
value: 'message attachment text quote',
},
],
},
],
message_link: 'http://localhost:3000/group/ds?msg=ZoX9pDowqNb4BiWxf',
author_name: 'b',
author_icon: '/avatar/b',
attachments: [],
ts: new Date('2025-07-24T18:05:45.339Z'),
},
],
u: {
_id: 'user-id',
username: 'username',
},
rid: 'room-id',
_updatedAt: new Date(),
}}
/>,
{ wrapper: mockAppRoot().build() },
);
expect(container).toMatchSnapshot();
const text = screen.getByText('http://localhost:3000/group/ds?msg=ZoX9pDowqNb4BiWxf');
expect(text).toBeInTheDocument;
});

View File

@@ -0,0 +1,28 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { StoryObj, Meta } from '@storybook/react';
import type { ComponentProps } from 'react';
import ThreadMessagePreviewBody from './ThreadMessagePreviewBody';
export default {
component: ThreadMessagePreviewBody,
parameters: {
layout: 'fullscreen',
},
decorators: [mockAppRoot().withSetting('UI_Use_Real_Name', true).withJohnDoe().buildStoryDecorator()],
args: {
message: {
_id: 'message-id',
ts: new Date(),
msg: 'This is a message',
u: {
_id: 'user-id',
username: 'username',
},
rid: 'room-id',
_updatedAt: new Date(),
},
},
} satisfies Meta<typeof ThreadMessagePreviewBody>;
export const Default: StoryObj<ComponentProps<typeof ThreadMessagePreviewBody>> = {};

View File

@@ -0,0 +1,60 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { isQuoteAttachment, isE2EEMessage } from '@rocket.chat/core-typings';
import { PreviewMarkup } from '@rocket.chat/gazzodown';
import type { Root } from '@rocket.chat/message-parser';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { toPlainTextRoot } from '../../../../lib/toPlainTextRoot';
import GazzodownText from '../../../GazzodownText';
export type ThreadMessagePreviewBodyProps = {
message: IMessage;
};
function getMdTokens(message: IMessage): Root | undefined {
if (message.md) {
return [...message.md];
}
if (message.msg) {
return toPlainTextRoot(message.msg);
}
}
const ThreadMessagePreviewBody = ({ message }: ThreadMessagePreviewBodyProps) => {
const { t } = useTranslation();
const isEncryptedMessage = isE2EEMessage(message);
const getMessage = () => {
const mdTokens = getMdTokens(message);
if (
message.attachments &&
Array.isArray(message.attachments) &&
message.attachments.length > 0 &&
isQuoteAttachment(message.attachments[0])
) {
mdTokens?.shift();
}
if (message.attachments && message.msg === '') {
return <>{t('Message_with_attachment')}</>;
}
if (!isEncryptedMessage || message.e2e === 'done') {
return mdTokens?.length ? (
<GazzodownText>
<PreviewMarkup tokens={mdTokens} />
</GazzodownText>
) : (
<>{message.msg}</>
);
}
if (isEncryptedMessage && message.e2e === 'pending') {
return <>{t('E2E_message_encrypted_placeholder')}</>;
}
return <>{message.msg}</>;
};
return getMessage();
};
export default memo(ThreadMessagePreviewBody);

View File

@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders ThreadMessagePreviewBody without crashing 1`] = `
<body>
<div>
This is a message
</div>
</body>
`;
exports[`should not show an empty thread preview 1`] = `
<div>
http://localhost:3000/group/ds?msg=ZoX9pDowqNb4BiWxf
</div>
`;