Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cool-flowers-stop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---

Adds `Message_MaxMarkdownParseLength` setting to limit the number of characters processed by the Markdown parser.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { render, screen } from '@testing-library/react';

import { QuoteAttachment } from './QuoteAttachment';

jest.mock('../../hooks/useMaxMarkdownParseLength', () => ({
useMaxMarkdownParseLength: () => 100,
}));

jest.mock('@rocket.chat/ui-contexts', () => ({
useUserPreference: () => true,
}));

jest.mock('../../../../hooks/useTimeAgo', () => ({
useTimeAgo: () => (date: Date) => date.toISOString(),
}));

jest.mock('../../MessageContentBody', () => ({
__esModule: true,
default: () => <div data-testid='message-content-body' />,
}));

const baseAttachment = {
author_name: 'User',
author_icon: '',
ts: new Date(),
text: 'short text',
md: [{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'short text' }] }],
};

describe('QuoteAttachment', () => {
it('renders MessageContentBody when text length is within maxMarkdownParseLength', () => {
render(<QuoteAttachment attachment={baseAttachment as any} />);
expect(screen.getByTestId('message-content-body')).toBeInTheDocument();
});

it('renders plain text when text exceeds maxMarkdownParseLength', () => {
const longText = 'a'.repeat(101);
const attachment = { ...baseAttachment, text: longText };

render(<QuoteAttachment attachment={attachment as any} />);

expect(screen.queryByTestId('message-content-body')).not.toBeInTheDocument();
expect(screen.getByText(longText)).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AttachmentAuthorName from './structure/AttachmentAuthorName';
import AttachmentContent from './structure/AttachmentContent';
import AttachmentDetails from './structure/AttachmentDetails';
import AttachmentInner from './structure/AttachmentInner';
import { useMaxMarkdownParseLength } from '../../hooks/useMaxMarkdownParseLength';

// TODO: remove this team collaboration
const quoteStyles = css`
Expand All @@ -38,6 +39,7 @@ type QuoteAttachmentProps = {
export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElement => {
const formatTime = useTimeAgo();
const displayAvatarPreference = useUserPreference<boolean>('displayAvatars');
const maxMarkdownParseLength = useMaxMarkdownParseLength();

return (
<>
Expand Down Expand Up @@ -71,7 +73,7 @@ export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElem
<Attachments attachments={attachment.attachments} id={attachment.attachments[0]?.title_link} />
</AttachmentInner>
)}
{attachment.md ? <MessageContentBody md={attachment.md} /> : attachment.text.substring(attachment.text.indexOf('\n') + 1)}
{attachment.text.length <= maxMarkdownParseLength && attachment.md ? <MessageContentBody md={attachment.md} /> : attachment.text}
</AttachmentDetails>
</AttachmentContent>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useSetting } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';

/**
* Returns the maximum number of characters a message can have for markdown parsing.
* Returns Infinity when the setting is 0 or negative, meaning the limit is disabled
* and all messages will be parsed regardless of length.
*/
export const useMaxMarkdownParseLength = (): number => {
const settingValue = useSetting('Message_MaxMarkdownParseLength', 0);

return useMemo(() => {
if (typeof settingValue !== 'number' || settingValue <= 0) {
return Infinity;
}
return settingValue;
Comment thread
nazabucciarelli marked this conversation as resolved.
}, [settingValue]);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { renderHook } from '@testing-library/react';

import { useNormalizedMessage } from './useNormalizedMessage';

const mockParseMessageTextToAstMarkdown = jest.fn((msg: any, ..._args: any[]) => msg);

jest.mock('../list/MessageListContext', () => ({
useMessageListKatex: () => null,
useMessageListAutoTranslate: () => ({
showAutoTranslate: () => false,
autoTranslateLanguage: '',
}),
useMessageListShowColors: () => false,
}));

jest.mock('../../../lib/parseMessageTextToAstMarkdown', () => ({
parseMessageTextToAstMarkdown: (msg: any, ...args: any[]) => mockParseMessageTextToAstMarkdown(msg, ...args),
}));

jest.mock('../../../views/room/MessageList/hooks/useAutoLinkDomains', () => ({ useAutoLinkDomains: () => [] }));

const baseMessage = {
_id: 'msg1',
rid: 'room1',
u: { _id: 'u1', username: 'user', name: 'User' },
ts: new Date(),
_updatedAt: new Date(),
};

describe('useNormalizedMessage', () => {
beforeEach(() => {
mockParseMessageTextToAstMarkdown.mockClear();
});

it('should skip parsing and returns PARAGRAPH node when msg exceeds maxMarkdownParseLength', () => {
const longMsg = 'a'.repeat(101);
const message = { ...baseMessage, msg: longMsg };

const { result } = renderHook(() => useNormalizedMessage(message as any, 100));

expect(mockParseMessageTextToAstMarkdown).not.toHaveBeenCalled();
expect(result.current.md).toEqual([
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: longMsg }],
},
]);
});

it('should call parseMessageTextToAstMarkdown when msg is within maxMarkdownParseLength', () => {
const message = { ...baseMessage, msg: 'Hello world' };

renderHook(() => useNormalizedMessage(message as any, 100));

expect(mockParseMessageTextToAstMarkdown).toHaveBeenCalledWith(message, expect.anything(), expect.anything());
});

it('should preserve attachments when bypassing parsing due to size', () => {
const longMsg = 'a'.repeat(101);
const attachments = [{ type: 'quote', text: 'quoted' }];
const message = { ...baseMessage, msg: longMsg, attachments };

const { result } = renderHook(() => useNormalizedMessage(message as any, 100));

expect(mockParseMessageTextToAstMarkdown).not.toHaveBeenCalled();
expect(result.current.attachments).toEqual(attachments);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ const normalizeAttachments = (attachments: MessageAttachment[], name?: string, t
});
};

export const useNormalizedMessage = <TMessage extends IMessage>(message: TMessage): MessageWithMdEnforced => {
export const useNormalizedMessage = <TMessage extends IMessage>(
message: TMessage,
maxMarkdownParseLength: number,
): MessageWithMdEnforced => {
const katex = useMessageListKatex();
const katexEnabled = !!katex;
const customDomains = useAutoLinkDomains();
Expand All @@ -77,6 +80,25 @@ export const useNormalizedMessage = <TMessage extends IMessage>(message: TMessag
}),
};

if (message.msg && message.msg.length > maxMarkdownParseLength) {
return {
...message,
Comment thread
nazabucciarelli marked this conversation as resolved.
md: [
{
type: 'PARAGRAPH',
value: [{ type: 'PLAIN_TEXT', value: message.msg }],
Comment thread
nazabucciarelli marked this conversation as resolved.
},
],
attachments: message.attachments
? normalizeAttachments(
message.attachments.map((a) => ({ ...a })),
message.file?.name,
message.file?.type,
)
: message.attachments,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}

const normalizedMessage = parseMessageTextToAstMarkdown(message, parseOptions, autoTranslateOptions);

if (normalizedMessage.attachments) {
Expand All @@ -88,5 +110,14 @@ export const useNormalizedMessage = <TMessage extends IMessage>(message: TMessag
}

return normalizedMessage;
}, [showColors, customDomains, katexEnabled, katex?.dollarSyntaxEnabled, katex?.parenthesisSyntaxEnabled, message, autoTranslateOptions]);
}, [
showColors,
customDomains,
katexEnabled,
katex?.dollarSyntaxEnabled,
katex?.parenthesisSyntaxEnabled,
message,
autoTranslateOptions,
maxMarkdownParseLength,
]);
};
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useGoToThread } from '../../../views/room/hooks/useGoToThread';
import Emoji from '../../Emoji';
import { useShowTranslated } from '../list/MessageListContext';
import ThreadMessagePreviewBody from './threadPreview/ThreadMessagePreviewBody';
import { useMaxMarkdownParseLength } from '../hooks/useMaxMarkdownParseLength';

type ThreadMessagePreviewProps = {
message: IThreadMessage;
Expand All @@ -51,7 +52,9 @@ const ThreadMessagePreview = ({ message, showUserAvatar, sequential, ...props }:
useCountSelected();

const messageType = parentMessage.isSuccess ? MessageTypes.getType(parentMessage.data) : null;
const messageBody = useMessageBody(parentMessage.data);

const maxMarkdownParseLength = useMaxMarkdownParseLength();
const messageBody = useMessageBody(parentMessage.data, maxMarkdownParseLength);

const previewMessage = isParsedMessage(messageBody) ? { md: messageBody } : { msg: messageBody };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import MessageActions from '../../content/MessageActions';
import Reactions from '../../content/Reactions';
import ThreadMetrics from '../../content/ThreadMetrics';
import UrlPreviews from '../../content/UrlPreviews';
import { useMaxMarkdownParseLength } from '../../hooks/useMaxMarkdownParseLength';
import { useNormalizedMessage } from '../../hooks/useNormalizedMessage';
import { useOembedLayout } from '../../hooks/useOembedLayout';
import { useSubscriptionFromMessageQuery } from '../../hooks/useSubscriptionFromMessageQuery';
Expand All @@ -42,8 +43,9 @@ const RoomMessageContent = ({ message, unread, all, mention, searchText }: RoomM
const messageUser = { ...message.u, roles: [], ...useUserPresence(message.u._id) };
const chat = useChat();
const { t } = useTranslation();
const maxMarkdownParseLength = useMaxMarkdownParseLength();

const normalizedMessage = useNormalizedMessage(message);
const normalizedMessage = useNormalizedMessage(message, maxMarkdownParseLength);
const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending';

const quotes = normalizedMessage?.attachments?.filter(isQuoteAttachment) || [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import Location from '../../content/Location';
import MessageActions from '../../content/MessageActions';
import Reactions from '../../content/Reactions';
import UrlPreviews from '../../content/UrlPreviews';
import { useMaxMarkdownParseLength } from '../../hooks/useMaxMarkdownParseLength';
import { useNormalizedMessage } from '../../hooks/useNormalizedMessage';
import { useOembedLayout } from '../../hooks/useOembedLayout';
import { useSubscriptionFromMessageQuery } from '../../hooks/useSubscriptionFromMessageQuery';
Expand All @@ -33,10 +34,11 @@ const ThreadMessageContent = ({ message }: ThreadMessageContentProps): ReactElem
const uid = useUserId();
const { enabled: readReceiptEnabled } = useMessageListReadReceipts();
const messageUser = { ...message.u, roles: [], ...useUserPresence(message.u._id) };
const maxMarkdownParseLength = useMaxMarkdownParseLength();

const { t } = useTranslation();

const normalizedMessage = useNormalizedMessage(message);
const normalizedMessage = useNormalizedMessage(message, maxMarkdownParseLength);

const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending';

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { renderHook } from '@testing-library/react';

import { useMessageBody } from './useMessageBody';

const mockParseMessageTextToAstMarkdown = jest.fn();

jest.mock('./useAutoLinkDomains', () => ({
useAutoLinkDomains: () => [],
}));

jest.mock('../../../../components/message/list/MessageListContext', () => ({
useMessageListAutoTranslate: () => ({
showAutoTranslate: () => false,
autoTranslateLanguage: '',
}),
}));

jest.mock('../../../../lib/parseMessageTextToAstMarkdown', () => ({
parseMessageTextToAstMarkdown: (msg: any, ...args: any[]) => mockParseMessageTextToAstMarkdown(msg, ...args),
}));

const baseMessage = {
_id: 'msg1',
rid: 'room1',
u: { _id: 'u1', username: 'user', name: 'User' },
ts: new Date(),
_updatedAt: new Date(),
};

describe('useMessageBody', () => {
beforeEach(() => {
mockParseMessageTextToAstMarkdown.mockClear();
});

it('should return raw msg and skips parsing when msg exceeds maxMarkdownParseLength', () => {
const longMsg = 'a'.repeat(101);
const message = { ...baseMessage, msg: longMsg, md: [{ type: 'PARAGRAPH', value: [] }] };

const { result } = renderHook(() => useMessageBody(message as any, 100));

expect(result.current).toBe(longMsg);
expect(mockParseMessageTextToAstMarkdown).not.toHaveBeenCalled();
});

it('should call parser when message has md and is within maxMarkdownParseLength', () => {
const md = [{ type: 'PARAGRAPH', value: [] }];
const message = { ...baseMessage, msg: 'Hello world', md };
mockParseMessageTextToAstMarkdown.mockReturnValue({ ...message, md });

const { result } = renderHook(() => useMessageBody(message as any, 100));

expect(mockParseMessageTextToAstMarkdown).toHaveBeenCalledWith(message, expect.anything(), expect.anything());
expect(result.current).toBe(md);
});

it('should return raw msg without parsing when message has no md', () => {
const message = { ...baseMessage, msg: 'Hello world' };

const { result } = renderHook(() => useMessageBody(message as any, 100));

expect(mockParseMessageTextToAstMarkdown).not.toHaveBeenCalled();
expect(result.current).toBe('Hello world');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useAutoLinkDomains } from './useAutoLinkDomains';
import { useMessageListAutoTranslate } from '../../../../components/message/list/MessageListContext';
import { parseMessageTextToAstMarkdown } from '../../../../lib/parseMessageTextToAstMarkdown';

export const useMessageBody = (message: IMessage | undefined): string | Root => {
export const useMessageBody = (message: IMessage | undefined, maxMarkdownParseLength: number): string | Root => {
const autoTranslateOptions = useMessageListAutoTranslate();
const customDomains = useAutoLinkDomains();

Expand All @@ -15,6 +15,10 @@ export const useMessageBody = (message: IMessage | undefined): string | Root =>
return '';
}

if (message.msg && message.msg.length > maxMarkdownParseLength) {
return message.msg;
}
Comment thread
nazabucciarelli marked this conversation as resolved.

if (message.md) {
const parseOptions: Options = {
customDomains,
Expand All @@ -39,5 +43,5 @@ export const useMessageBody = (message: IMessage | undefined): string | Root =>
}

return '';
}, [message, customDomains, autoTranslateOptions]);
}, [message, customDomains, autoTranslateOptions, maxMarkdownParseLength]);
};
5 changes: 5 additions & 0 deletions apps/meteor/server/settings/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ export const createMessageSettings = () =>
type: 'int',
public: true,
});
await this.add('Message_MaxMarkdownParseLength', 0, {
type: 'int',
public: true,
i18nDescription: 'Message_MaxMarkdownParseLength_Description',
});
Comment thread
nazabucciarelli marked this conversation as resolved.
await this.add('Message_AllowConvertLongMessagesToAttachment', true, {
type: 'boolean',
public: true,
Expand Down
2 changes: 2 additions & 0 deletions packages/i18n/src/locales/en.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -3517,6 +3517,8 @@
"Message_KeepHistory": "Keep Per Message Editing History",
"Message_MaxAll": "Maximum Channel Size for ALL Message",
"Message_MaxAllowedSize": "Maximum Allowed Characters Per Message",
"Message_MaxMarkdownParseLength": "Maximum Markdown Parsing Characters",
"Message_MaxMarkdownParseLength_Description": "Maximum number of characters per message to be parsed as Markdown. Messages longer than this will be rendered as plain text. Set to 0 to disable this limit. Warning: High values may significantly impact client-side performance.",
"Message_not_sent_try_again": "Message not sent. \nPlease try again",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"Message_QuoteChainLimit": "Maximum Number of Chained Quotes",
"Message_Read_Receipt_Enabled": "Show Read Receipts",
Expand Down
Loading