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
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:
@@ -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();
|
||||
});
|
||||
152
apps/meteor/client/components/message/variants/RoomMessage.tsx
Normal file
152
apps/meteor/client/components/message/variants/RoomMessage.tsx
Normal 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);
|
||||
@@ -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: & test &')).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();
|
||||
});
|
||||
});
|
||||
131
apps/meteor/client/components/message/variants/SystemMessage.tsx
Normal file
131
apps/meteor/client/components/message/variants/SystemMessage.tsx
Normal 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);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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>> = {};
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
`;
|
||||
Reference in New Issue
Block a user