diff --git a/.changeset/cool-flowers-stop.md b/.changeset/cool-flowers-stop.md new file mode 100644 index 0000000000000..c45b3306ffc0c --- /dev/null +++ b/.changeset/cool-flowers-stop.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Fixes workspace freezing when rendering massive markdown messages diff --git a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.spec.tsx b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.spec.tsx new file mode 100644 index 0000000000000..315bbf103da46 --- /dev/null +++ b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.spec.tsx @@ -0,0 +1,44 @@ +import { render, screen } from '@testing-library/react'; + +import { QuoteAttachment } from './QuoteAttachment'; + +jest.mock('../../hooks/useMaxMessageParseSize', () => ({ + useMaxMessageParseSize: () => 100, +})); + +jest.mock('@rocket.chat/ui-contexts', () => ({ + useUserPreference: () => true, +})); + +jest.mock('../../../../hooks/useTimeAgo', () => ({ + useTimeAgo: () => (date: Date) => date.toISOString(), +})); + +jest.mock('../../MessageContentBody', () => ({ + __esModule: true, + default: () =>
, +})); + +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 maxMessageParseSize', () => { + render(); + expect(screen.getByTestId('message-content-body')).toBeInTheDocument(); + }); + + it('renders plain text when text exceeds maxMessageParseSize', () => { + const longText = 'a'.repeat(101); + const attachment = { ...baseAttachment, text: longText }; + + render(); + + expect(screen.queryByTestId('message-content-body')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx index 2a4f975c6641a..271de136a2e8a 100644 --- a/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx +++ b/apps/meteor/client/components/message/content/attachments/QuoteAttachment.tsx @@ -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 { useMaxMessageParseSize } from '../../hooks/useMaxMessageParseSize'; // TODO: remove this team collaboration const quoteStyles = css` @@ -38,6 +39,7 @@ type QuoteAttachmentProps = { export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElement => { const formatTime = useTimeAgo(); const displayAvatarPreference = useUserPreference('displayAvatars'); + const maxMessageParseSize = useMaxMessageParseSize(); return ( <> @@ -71,7 +73,11 @@ export const QuoteAttachment = ({ attachment }: QuoteAttachmentProps): ReactElem )} - {attachment.md ? : attachment.text.substring(attachment.text.indexOf('\n') + 1)} + {attachment.text.length <= maxMessageParseSize && attachment.md ? ( + + ) : ( + attachment.text.substring(attachment.text.indexOf('\n') + 1) + )} diff --git a/apps/meteor/client/components/message/hooks/useMaxMessageParseSize.ts b/apps/meteor/client/components/message/hooks/useMaxMessageParseSize.ts new file mode 100644 index 0000000000000..342a03aa0d7a5 --- /dev/null +++ b/apps/meteor/client/components/message/hooks/useMaxMessageParseSize.ts @@ -0,0 +1,18 @@ +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { MESSAGE_PARSE_HARD_LIMIT } from '../../../lib/constants'; + +/** + * Returns the maximum allowed size for message parsing. + * Uses Math.min to ensure it never exceeds the hard limit to avoid performance issues. + * Always returns a number, never null or undefined. + */ +export const useMaxMessageParseSize = (): number => { + const settingValue = useSetting('Message_MaxAllowedSize', 5000); + + return useMemo(() => { + const maxSize = typeof settingValue === 'number' && settingValue > 0 ? settingValue : 5000; + return Math.min(maxSize, MESSAGE_PARSE_HARD_LIMIT); + }, [settingValue]); +}; diff --git a/apps/meteor/client/components/message/hooks/useNormalizedMessage.spec.ts b/apps/meteor/client/components/message/hooks/useNormalizedMessage.spec.ts new file mode 100644 index 0000000000000..b16ad0bd55699 --- /dev/null +++ b/apps/meteor/client/components/message/hooks/useNormalizedMessage.spec.ts @@ -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 maxMessageParseSize', () => { + 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 maxMessageParseSize', () => { + 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); + }); +}); diff --git a/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts b/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts index a2a8047560ee4..8fda6978c0e46 100644 --- a/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts +++ b/apps/meteor/client/components/message/hooks/useNormalizedMessage.ts @@ -57,7 +57,7 @@ const normalizeAttachments = (attachments: MessageAttachment[], name?: string, t }); }; -export const useNormalizedMessage = (message: TMessage): MessageWithMdEnforced => { +export const useNormalizedMessage = (message: TMessage, maxMessageParseSize: number): MessageWithMdEnforced => { const katex = useMessageListKatex(); const katexEnabled = !!katex; const customDomains = useAutoLinkDomains(); @@ -77,6 +77,21 @@ export const useNormalizedMessage = (message: TMessag }), }; + if (message.msg && message.msg.length > maxMessageParseSize) { + return { + ...message, + md: [ + { + type: 'PARAGRAPH', + value: [{ type: 'PLAIN_TEXT', value: message.msg }], + }, + ], + attachments: message.attachments + ? normalizeAttachments(message.attachments, message.file?.name, message.file?.type) + : message.attachments, + }; + } + const normalizedMessage = parseMessageTextToAstMarkdown(message, parseOptions, autoTranslateOptions); if (normalizedMessage.attachments) { @@ -88,5 +103,14 @@ export const useNormalizedMessage = (message: TMessag } return normalizedMessage; - }, [showColors, customDomains, katexEnabled, katex?.dollarSyntaxEnabled, katex?.parenthesisSyntaxEnabled, message, autoTranslateOptions]); + }, [ + showColors, + customDomains, + katexEnabled, + katex?.dollarSyntaxEnabled, + katex?.parenthesisSyntaxEnabled, + message, + autoTranslateOptions, + maxMessageParseSize, + ]); }; diff --git a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx index 7aec9412fd07d..1723cd0675948 100644 --- a/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx +++ b/apps/meteor/client/components/message/variants/ThreadMessagePreview.tsx @@ -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 { useMaxMessageParseSize } from '../hooks/useMaxMessageParseSize'; type ThreadMessagePreviewProps = { message: IThreadMessage; @@ -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 maxMessageParseSize = useMaxMessageParseSize(); + const messageBody = useMessageBody(parentMessage.data, maxMessageParseSize); const previewMessage = isParsedMessage(messageBody) ? { md: messageBody } : { msg: messageBody }; diff --git a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx index 813d63e9f9130..6a6a7a05af46f 100644 --- a/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/room/RoomMessageContent.tsx @@ -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 { useMaxMessageParseSize } from '../../hooks/useMaxMessageParseSize'; import { useNormalizedMessage } from '../../hooks/useNormalizedMessage'; import { useOembedLayout } from '../../hooks/useOembedLayout'; import { useSubscriptionFromMessageQuery } from '../../hooks/useSubscriptionFromMessageQuery'; @@ -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 maxMessageParseSize = useMaxMessageParseSize(); - const normalizedMessage = useNormalizedMessage(message); + const normalizedMessage = useNormalizedMessage(message, maxMessageParseSize); const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending'; const quotes = normalizedMessage?.attachments?.filter(isQuoteAttachment) || []; diff --git a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx index d137162d0b06d..1f26ec6cd6431 100644 --- a/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx +++ b/apps/meteor/client/components/message/variants/thread/ThreadMessageContent.tsx @@ -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 { useMaxMessageParseSize } from '../../hooks/useMaxMessageParseSize'; import { useNormalizedMessage } from '../../hooks/useNormalizedMessage'; import { useOembedLayout } from '../../hooks/useOembedLayout'; import { useSubscriptionFromMessageQuery } from '../../hooks/useSubscriptionFromMessageQuery'; @@ -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 maxMessageParseSize = useMaxMessageParseSize(); const { t } = useTranslation(); - const normalizedMessage = useNormalizedMessage(message); + const normalizedMessage = useNormalizedMessage(message, maxMessageParseSize); const isMessageEncrypted = encrypted && normalizedMessage?.e2e === 'pending'; diff --git a/apps/meteor/client/lib/constants.ts b/apps/meteor/client/lib/constants.ts index 70cae5deff224..cd57c13d92078 100644 --- a/apps/meteor/client/lib/constants.ts +++ b/apps/meteor/client/lib/constants.ts @@ -3,3 +3,4 @@ export const BIO_TEXT_MAX_LENGTH = 260; export const VIDEOCONF_STACK_MAX_USERS = 6; export const NAVIGATION_REGION_ID = 'navigation-region'; export const MAX_FILE_SIZE_PREVIEW = 10485760; // 10MB +export const MESSAGE_PARSE_HARD_LIMIT = 20000; diff --git a/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.spec.ts b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.spec.ts new file mode 100644 index 0000000000000..6890a3a7f4f0e --- /dev/null +++ b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.spec.ts @@ -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 maxMessageParseSize', () => { + 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 maxMessageParseSize', () => { + 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'); + }); +}); diff --git a/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx index 94313e6925432..09c1201a9cced 100644 --- a/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx +++ b/apps/meteor/client/views/room/MessageList/hooks/useMessageBody.tsx @@ -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, maxMessageParseSize: number): string | Root => { const autoTranslateOptions = useMessageListAutoTranslate(); const customDomains = useAutoLinkDomains(); @@ -15,6 +15,10 @@ export const useMessageBody = (message: IMessage | undefined): string | Root => return ''; } + if (message.msg && message.msg.length > maxMessageParseSize) { + return message.msg; + } + if (message.md) { const parseOptions: Options = { customDomains, @@ -39,5 +43,5 @@ export const useMessageBody = (message: IMessage | undefined): string | Root => } return ''; - }, [message, customDomains, autoTranslateOptions]); + }, [message, customDomains, autoTranslateOptions, maxMessageParseSize]); }; diff --git a/apps/meteor/server/settings/message.ts b/apps/meteor/server/settings/message.ts index 734d3831eebef..f1d6f433616b2 100644 --- a/apps/meteor/server/settings/message.ts +++ b/apps/meteor/server/settings/message.ts @@ -168,6 +168,7 @@ export const createMessageSettings = () => await this.add('Message_MaxAllowedSize', 5000, { type: 'int', public: true, + i18nDescription: 'Message_MaxAllowedSize_Description', }); await this.add('Message_AllowConvertLongMessagesToAttachment', true, { type: 'boolean', diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 52113dc9a07b8..8403a6c57deae 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -3517,6 +3517,7 @@ "Message_KeepHistory": "Keep Per Message Editing History", "Message_MaxAll": "Maximum Channel Size for ALL Message", "Message_MaxAllowedSize": "Maximum Allowed Characters Per Message", + "Message_MaxAllowedSize_Description": "Maximum number of characters allowed per message. Messages exceeding this limit will be displayed as plain text without markdown parsing.", "Message_not_sent_try_again": "Message not sent. \nPlease try again", "Message_QuoteChainLimit": "Maximum Number of Chained Quotes", "Message_Read_Receipt_Enabled": "Show Read Receipts",