diff --git a/apps/meteor/client/components/MarkdownText.spec.tsx b/apps/meteor/client/components/MarkdownText.spec.tsx
index a59c53cc14012..b4e6ddc92a147 100644
--- a/apps/meteor/client/components/MarkdownText.spec.tsx
+++ b/apps/meteor/client/components/MarkdownText.spec.tsx
@@ -3,8 +3,12 @@ import { render, screen } from '@testing-library/react';
import dompurify from 'dompurify';
import MarkdownText, { supportedURISchemes } from './MarkdownText';
-
import '@testing-library/jest-dom';
+import { getMarkdownParserLimit } from '../lib/getMarkdownParserLimit';
+
+jest.mock('../lib/getMarkdownParserLimit');
+
+const getMarkdownParserLimitMock = jest.mocked(getMarkdownParserLimit);
const MOCKED_BASE_URI = 'http://localhost/';
@@ -499,3 +503,49 @@ describe('DOMPurify hook registration', () => {
addHookSpy.mockRestore();
});
});
+
+describe('parser limit handling', () => {
+ beforeEach(() => {
+ getMarkdownParserLimitMock.mockClear();
+ });
+
+ it('should render plain text without parsing when content exceeds the limit', () => {
+ getMarkdownParserLimitMock.mockReturnValue(5);
+
+ const longContent = '**this should not be bold** because it exceeds the limit';
+ render(, {
+ wrapper: mockAppRoot().build(),
+ });
+
+ const element = screen.getByText(longContent);
+ expect(element).toBeInTheDocument();
+ expect(element.tagName).not.toBe('STRONG');
+ });
+
+ it('should render parsed markdown when content is within the limit', () => {
+ getMarkdownParserLimitMock.mockReturnValue(Infinity);
+
+ const content = '**bold text**';
+ render(, {
+ wrapper: mockAppRoot().build(),
+ });
+
+ const boldText = screen.getByText('bold text');
+ expect(boldText).toBeInTheDocument();
+ expect(boldText.tagName).toBe('STRONG');
+ });
+
+ it('should render parsed markdown when content length equals the limit', () => {
+ const content = '**hi**';
+
+ getMarkdownParserLimitMock.mockReturnValue(content.length);
+
+ render(, {
+ wrapper: mockAppRoot().build(),
+ });
+
+ const parsedText = screen.getByText('hi');
+ expect(parsedText).toBeInTheDocument();
+ expect(parsedText.tagName).toBe('STRONG');
+ });
+});
diff --git a/apps/meteor/client/components/MarkdownText.tsx b/apps/meteor/client/components/MarkdownText.tsx
index faefcf806d54b..55dd93a8c5e27 100644
--- a/apps/meteor/client/components/MarkdownText.tsx
+++ b/apps/meteor/client/components/MarkdownText.tsx
@@ -1,12 +1,10 @@
import { Box } from '@rocket.chat/fuselage';
-import { isExternal, getBaseURI } from '@rocket.chat/ui-client';
-import dompurify from 'dompurify';
-import { marked } from 'marked';
import type { ComponentProps } from 'react';
-import { useMemo } from 'react';
-import { useTranslation } from 'react-i18next';
-import { renderMessageEmoji } from '../lib/utils/renderMessageEmoji';
+import MarkdownTextInner from './MarkdownTextInner';
+import { getMarkdownParserLimit } from '../lib/getMarkdownParserLimit';
+
+export { supportedURISchemes } from './MarkdownTextInner';
type MarkdownTextParams = {
content: string;
@@ -16,197 +14,26 @@ type MarkdownTextParams = {
withTruncatedText: boolean;
} & ComponentProps;
-const documentRenderer = new marked.Renderer();
-const inlineRenderer = new marked.Renderer();
-const inlineWithoutBreaks = new marked.Renderer();
-
-const walkTokens = (token: marked.Token) => {
- const boldPattern = /^\*[^*]+\*$|^\*\*[^*]+\*\*$/;
- const italicPattern = /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/;
- if (boldPattern.test(token.raw) && token.type === 'em') {
- token.type = 'strong' as 'em';
- } else if (italicPattern.test(token.raw) && token.type === 'strong') {
- token.type = 'em' as 'strong';
- }
-};
-
-marked.use({ walkTokens });
-
-const linkMarked = (href: string | null, _title: string | null, text: string): string => {
- return `${text}`;
-};
-const paragraphMarked = (text: string): string => text;
-const brMarked = (): string => ' ';
-const listItemMarked = (text: string): string => {
- const cleanText = text.replace(/|<\/p>/gi, '');
- return `${cleanText}`;
-};
-const horizontalRuleMarked = (): string => '';
-const codeMarked = (code: string, language: string | undefined, _isEscaped: boolean): string => {
- if (language) {
- return `${code}
`;
- }
- return `${code}
`;
-};
-const codespanMarked = (code: string): string => {
- return `${code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')}`;
-};
-
-documentRenderer.link = linkMarked;
-documentRenderer.listitem = listItemMarked;
-documentRenderer.code = codeMarked;
-documentRenderer.codespan = codespanMarked;
-
-inlineRenderer.link = linkMarked;
-inlineRenderer.paragraph = paragraphMarked;
-inlineRenderer.listitem = listItemMarked;
-inlineRenderer.hr = horizontalRuleMarked;
-
-inlineWithoutBreaks.link = linkMarked;
-inlineWithoutBreaks.paragraph = paragraphMarked;
-inlineWithoutBreaks.br = brMarked;
-inlineWithoutBreaks.image = brMarked;
-inlineWithoutBreaks.code = paragraphMarked;
-inlineWithoutBreaks.codespan = paragraphMarked;
-inlineWithoutBreaks.listitem = listItemMarked;
-inlineWithoutBreaks.hr = horizontalRuleMarked;
-
-const defaultOptions = {
- gfm: true,
- headerIds: false,
-};
-
-const options = {
- ...defaultOptions,
- breaks: true,
- renderer: documentRenderer,
-};
-
-const inlineOptions = {
- ...defaultOptions,
- renderer: inlineRenderer,
-};
-
-const inlineWithoutBreaksOptions = {
- ...defaultOptions,
- renderer: inlineWithoutBreaks,
-};
-
-const getRegexp = (supportedURISchemes: string[]): RegExp => {
- const schemes = supportedURISchemes.join('|');
-
- return new RegExp(`^(${schemes}):`, 'im');
-};
-
type MarkdownTextProps = Partial;
-export const supportedURISchemes = ['http', 'https', 'notes', 'ftp', 'ftps', 'tel', 'mailto', 'sms', 'cid'];
-
-const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE;
-const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a';
-
-// Generate a unique token at runtime to prevent enumeration attacks
-// This token marks internal links that need translation
-const INTERNAL_LINK_TOKEN = `__INTERNAL_LINK_TITLE_${Math.random().toString(36).substring(2, 15)}__`;
-
-// Register the DOMPurify hook once at module level to prevent memory leaks
-// This hook will be shared by all MarkdownText component instances
-dompurify.addHook('afterSanitizeAttributes', (node) => {
- if (!isLinkElement(node)) {
- return;
- }
-
- const href = node.getAttribute('href') || '';
- const isExternalLink = isExternal(href);
- const isMailto = href.startsWith('mailto:');
-
- // Set appropriate attributes based on link type
- if (isExternalLink || isMailto) {
- node.setAttribute('rel', 'nofollow noopener noreferrer');
- // Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat
- // This attribute must be preserved to guarantee users maintain their chat context
- node.setAttribute('target', '_blank');
- }
-
- // Set appropriate title based on link type
- if (isMailto) {
- // For mailto links, use the email address as the title for better user experience
- // Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com"
- node.setAttribute('title', href);
- } else if (isExternalLink) {
- // For external links, set an empty title to prevent tooltips
- // This reduces visual clutter and lets users see the URL in the browser's status bar instead
- node.setAttribute('title', '');
- } else {
- // For internal links, use a token that will be replaced with translated text in the component
- // This allows us to use the contextualized translation function
- const relativePath = href.replace(getBaseURI(), '');
- node.setAttribute('title', `${INTERNAL_LINK_TOKEN}${relativePath}`);
- }
-});
-
-const MarkdownText = ({
- content,
- variant = 'document',
- withTruncatedText = false,
- preserveHtml = false,
- parseEmoji = false,
- ...props
-}: MarkdownTextProps) => {
- const sanitizer = dompurify.sanitize;
- const { t } = useTranslation();
- let markedOptions: marked.MarkedOptions;
-
- switch (variant) {
- case 'inline':
- markedOptions = inlineOptions;
- break;
- case 'inlineWithoutBreaks':
- markedOptions = inlineWithoutBreaksOptions;
- break;
- case 'document':
- default:
- markedOptions = options;
+const MarkdownText = ({ content, withTruncatedText = false, variant, preserveHtml, parseEmoji, ...boxProps }: MarkdownTextProps) => {
+ if (content && content.length > getMarkdownParserLimit()) {
+ return (
+
+ {content}
+
+ );
}
-
- const __html = useMemo(() => {
- const html = ((): any => {
- if (content && typeof content === 'string') {
- const markedHtml = /inline/.test(variant)
- ? marked.parseInline(new Option(content).innerHTML, markedOptions)
- : marked.parse(new Option(content).innerHTML, markedOptions);
-
- if (parseEmoji) {
- // We are using the old emoji parser here. This could come
- // with additional processing use, but is the workaround available right now.
- // Should be replaced in the future with the new parser.
- return renderMessageEmoji(markedHtml);
- }
-
- return markedHtml;
- }
- })();
-
- const sanitizedHtml = preserveHtml
- ? html
- : html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) });
-
- // Replace internal link tokens with contextualized translations
- if (sanitizedHtml && typeof sanitizedHtml === 'string') {
- return sanitizedHtml.replace(new RegExp(`${INTERNAL_LINK_TOKEN}([^"]*)`, 'g'), (_, href) => t('Go_to_href', { href }));
- }
-
- return sanitizedHtml;
- }, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t]);
-
- return __html ? (
-
- ) : null;
+ );
};
export default MarkdownText;
diff --git a/apps/meteor/client/components/MarkdownTextInner.tsx b/apps/meteor/client/components/MarkdownTextInner.tsx
new file mode 100644
index 0000000000000..5bec747a555fe
--- /dev/null
+++ b/apps/meteor/client/components/MarkdownTextInner.tsx
@@ -0,0 +1,212 @@
+import { Box } from '@rocket.chat/fuselage';
+import { isExternal, getBaseURI } from '@rocket.chat/ui-client';
+import dompurify from 'dompurify';
+import { marked } from 'marked';
+import type { ComponentProps } from 'react';
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { renderMessageEmoji } from '../lib/utils/renderMessageEmoji';
+
+type MarkdownTextParams = {
+ content: string;
+ variant: 'inline' | 'inlineWithoutBreaks' | 'document';
+ preserveHtml: boolean;
+ parseEmoji: boolean;
+ withTruncatedText: boolean;
+} & ComponentProps;
+
+const documentRenderer = new marked.Renderer();
+const inlineRenderer = new marked.Renderer();
+const inlineWithoutBreaks = new marked.Renderer();
+
+const walkTokens = (token: marked.Token) => {
+ const boldPattern = /^\*[^*]+\*$|^\*\*[^*]+\*\*$/;
+ const italicPattern = /^__(?=\S)([\s\S]*?\S)__(?!_)|^_(?=\S)([\s\S]*?\S)_(?!_)/;
+ if (boldPattern.test(token.raw) && token.type === 'em') {
+ token.type = 'strong' as 'em';
+ } else if (italicPattern.test(token.raw) && token.type === 'strong') {
+ token.type = 'em' as 'strong';
+ }
+};
+
+marked.use({ walkTokens });
+
+const linkMarked = (href: string | null, _title: string | null, text: string): string => {
+ return `${text}`;
+};
+const paragraphMarked = (text: string): string => text;
+const brMarked = (): string => ' ';
+const listItemMarked = (text: string): string => {
+ const cleanText = text.replace(/|<\/p>/gi, '');
+ return `${cleanText}`;
+};
+const horizontalRuleMarked = (): string => '';
+const codeMarked = (code: string, language: string | undefined, _isEscaped: boolean): string => {
+ if (language) {
+ return `${code}
`;
+ }
+ return `${code}
`;
+};
+const codespanMarked = (code: string): string => {
+ return `${code.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&')}`;
+};
+
+documentRenderer.link = linkMarked;
+documentRenderer.listitem = listItemMarked;
+documentRenderer.code = codeMarked;
+documentRenderer.codespan = codespanMarked;
+
+inlineRenderer.link = linkMarked;
+inlineRenderer.paragraph = paragraphMarked;
+inlineRenderer.listitem = listItemMarked;
+inlineRenderer.hr = horizontalRuleMarked;
+
+inlineWithoutBreaks.link = linkMarked;
+inlineWithoutBreaks.paragraph = paragraphMarked;
+inlineWithoutBreaks.br = brMarked;
+inlineWithoutBreaks.image = brMarked;
+inlineWithoutBreaks.code = paragraphMarked;
+inlineWithoutBreaks.codespan = paragraphMarked;
+inlineWithoutBreaks.listitem = listItemMarked;
+inlineWithoutBreaks.hr = horizontalRuleMarked;
+
+const defaultOptions = {
+ gfm: true,
+ headerIds: false,
+};
+
+const options = {
+ ...defaultOptions,
+ breaks: true,
+ renderer: documentRenderer,
+};
+
+const inlineOptions = {
+ ...defaultOptions,
+ renderer: inlineRenderer,
+};
+
+const inlineWithoutBreaksOptions = {
+ ...defaultOptions,
+ renderer: inlineWithoutBreaks,
+};
+
+const getRegexp = (supportedURISchemes: string[]): RegExp => {
+ const schemes = supportedURISchemes.join('|');
+
+ return new RegExp(`^(${schemes}):`, 'im');
+};
+
+export type MarkdownTextInnerProps = Partial;
+
+export const supportedURISchemes = ['http', 'https', 'notes', 'ftp', 'ftps', 'tel', 'mailto', 'sms', 'cid'];
+
+const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE;
+const isLinkElement = (node: Node): node is HTMLAnchorElement => isElement(node) && node.tagName.toLowerCase() === 'a';
+
+// Generate a unique token at runtime to prevent enumeration attacks
+// This token marks internal links that need translation
+const INTERNAL_LINK_TOKEN = `__INTERNAL_LINK_TITLE_${Math.random().toString(36).substring(2, 15)}__`;
+
+// Register the DOMPurify hook once at module level to prevent memory leaks
+// This hook will be shared by all MarkdownText component instances
+dompurify.addHook('afterSanitizeAttributes', (node) => {
+ if (!isLinkElement(node)) {
+ return;
+ }
+
+ const href = node.getAttribute('href') || '';
+ const isExternalLink = isExternal(href);
+ const isMailto = href.startsWith('mailto:');
+
+ // Set appropriate attributes based on link type
+ if (isExternalLink || isMailto) {
+ node.setAttribute('rel', 'nofollow noopener noreferrer');
+ // Enforcing external links to open in new tabs is critical to assure users never navigate away from the chat
+ // This attribute must be preserved to guarantee users maintain their chat context
+ node.setAttribute('target', '_blank');
+ }
+
+ // Set appropriate title based on link type
+ if (isMailto) {
+ // For mailto links, use the email address as the title for better user experience
+ // Example: for href "mailto:user@example.com" the title would be "mailto:user@example.com"
+ node.setAttribute('title', href);
+ } else if (isExternalLink) {
+ // For external links, set an empty title to prevent tooltips
+ // This reduces visual clutter and lets users see the URL in the browser's status bar instead
+ node.setAttribute('title', '');
+ } else {
+ // For internal links, use a token that will be replaced with translated text in the component
+ // This allows us to use the contextualized translation function
+ const relativePath = href.replace(getBaseURI(), '');
+ node.setAttribute('title', `${INTERNAL_LINK_TOKEN}${relativePath}`);
+ }
+});
+
+const MarkdownTextInner = ({
+ content,
+ variant = 'document',
+ withTruncatedText = false,
+ preserveHtml = false,
+ parseEmoji = false,
+ ...boxProps
+}: MarkdownTextInnerProps) => {
+ const sanitizer = dompurify.sanitize;
+ const { t } = useTranslation();
+ let markedOptions: marked.MarkedOptions;
+
+ switch (variant) {
+ case 'inline':
+ markedOptions = inlineOptions;
+ break;
+ case 'inlineWithoutBreaks':
+ markedOptions = inlineWithoutBreaksOptions;
+ break;
+ case 'document':
+ default:
+ markedOptions = options;
+ }
+
+ const __html = useMemo(() => {
+ const html = ((): any => {
+ if (content && typeof content === 'string') {
+ const markedHtml = /inline/.test(variant)
+ ? marked.parseInline(new Option(content).innerHTML, markedOptions)
+ : marked.parse(new Option(content).innerHTML, markedOptions);
+
+ if (parseEmoji) {
+ // We are using the old emoji parser here. This could come
+ // with additional processing use, but is the workaround available right now.
+ // Should be replaced in the future with the new parser.
+ return renderMessageEmoji(markedHtml);
+ }
+
+ return markedHtml;
+ }
+ })();
+
+ const sanitizedHtml = preserveHtml
+ ? html
+ : html && sanitizer(html, { ADD_ATTR: ['target'], ALLOWED_URI_REGEXP: getRegexp(supportedURISchemes) });
+
+ // Replace internal link tokens with contextualized translations
+ if (sanitizedHtml && typeof sanitizedHtml === 'string') {
+ return sanitizedHtml.replace(new RegExp(`${INTERNAL_LINK_TOKEN}([^"]*)`, 'g'), (_, href) => t('Go_to_href', { href }));
+ }
+
+ return sanitizedHtml;
+ }, [preserveHtml, sanitizer, content, variant, markedOptions, parseEmoji, t]);
+
+ return __html ? (
+
+ ) : null;
+};
+
+export default MarkdownTextInner;
diff --git a/apps/meteor/client/lib/getMarkdownParserLimit.spec.ts b/apps/meteor/client/lib/getMarkdownParserLimit.spec.ts
new file mode 100644
index 0000000000000..c5c527deb5cfc
--- /dev/null
+++ b/apps/meteor/client/lib/getMarkdownParserLimit.spec.ts
@@ -0,0 +1,67 @@
+import { getMarkdownParserLimit } from './getMarkdownParserLimit';
+import { getPageMeta } from './getPageMeta';
+
+jest.mock('./getPageMeta');
+
+const getPageMetaMock = jest.mocked(getPageMeta);
+
+let mockDefaultLimit = 0;
+
+jest.mock('../../lib/constants', () => ({
+ get MESSAGE_MAX_PARSE_LENGTH_DEFAULT() {
+ return mockDefaultLimit;
+ },
+}));
+
+describe('getMarkdownParserLimit', () => {
+ beforeEach(() => {
+ getPageMetaMock.mockClear();
+ mockDefaultLimit = 0;
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('should return Infinity when meta tag is null and default constant is 0', () => {
+ getPageMetaMock.mockReturnValue(null);
+ mockDefaultLimit = 0;
+
+ expect(getMarkdownParserLimit()).toBe(Infinity);
+ });
+
+ it('should return the default constant when meta tag is null and constant > 0', () => {
+ getPageMetaMock.mockReturnValue(null);
+ mockDefaultLimit = 5000;
+
+ expect(getMarkdownParserLimit()).toBe(5000);
+ });
+
+ it('should return the parsed meta value when it is a valid positive number', () => {
+ getPageMetaMock.mockReturnValue('10000');
+ mockDefaultLimit = 0;
+
+ expect(getMarkdownParserLimit()).toBe(10000);
+ });
+
+ it('should return Infinity when meta value is "0"', () => {
+ getPageMetaMock.mockReturnValue('0');
+ mockDefaultLimit = 0;
+
+ expect(getMarkdownParserLimit()).toBe(Infinity);
+ });
+
+ it('should return Infinity when meta value is a negative number', () => {
+ getPageMetaMock.mockReturnValue('-5');
+ mockDefaultLimit = 0;
+
+ expect(getMarkdownParserLimit()).toBe(Infinity);
+ });
+
+ it('should return defaultValue when meta value is not a finite number (NaN)', () => {
+ getPageMetaMock.mockReturnValue('abc');
+ mockDefaultLimit = 0;
+
+ expect(getMarkdownParserLimit()).toBe(Infinity);
+ });
+});
diff --git a/apps/meteor/client/lib/getMarkdownParserLimit.ts b/apps/meteor/client/lib/getMarkdownParserLimit.ts
new file mode 100644
index 0000000000000..ecb807398431b
--- /dev/null
+++ b/apps/meteor/client/lib/getMarkdownParserLimit.ts
@@ -0,0 +1,17 @@
+import { getPageMeta } from './getPageMeta';
+import { MESSAGE_MAX_PARSE_LENGTH_DEFAULT } from '../../lib/constants';
+
+export const getMarkdownParserLimit = (): number => {
+ const value = getPageMeta('rc-message-parser-max-length');
+
+ const defaultValue = MESSAGE_MAX_PARSE_LENGTH_DEFAULT > 0 ? MESSAGE_MAX_PARSE_LENGTH_DEFAULT : Infinity;
+ if (value === null) return defaultValue;
+
+ const parsed = parseInt(value, 10);
+
+ if (!Number.isFinite(parsed)) {
+ return defaultValue;
+ }
+
+ return parsed > 0 ? parsed : Infinity;
+};
diff --git a/apps/meteor/client/lib/getPageMeta.spec.ts b/apps/meteor/client/lib/getPageMeta.spec.ts
new file mode 100644
index 0000000000000..8fd4051bff916
--- /dev/null
+++ b/apps/meteor/client/lib/getPageMeta.spec.ts
@@ -0,0 +1,38 @@
+import { getPageMeta } from './getPageMeta';
+
+describe('getPageMeta', () => {
+ beforeEach(() => {
+ document.head.innerHTML = '';
+ });
+
+ it('should return the content attribute of the meta tag', () => {
+ const meta = document.createElement('meta');
+ meta.setAttribute('name', 'test-meta');
+ meta.setAttribute('content', '12345');
+ document.head.appendChild(meta);
+
+ const result = getPageMeta('test-meta');
+ expect(result).toBe('12345');
+ });
+
+ it('should return null when the meta tag does not exist', () => {
+ const result = getPageMeta('missing-meta-tag');
+ expect(result).toBeNull();
+ });
+
+ it('should cache the result and return from cache on subsequent calls', () => {
+ const meta = document.createElement('meta');
+ meta.setAttribute('name', 'cached-meta');
+ meta.setAttribute('content', 'cached-value');
+ document.head.appendChild(meta);
+
+ const first = getPageMeta('cached-meta');
+ // Remove the meta tag
+ document.head.removeChild(meta);
+ // Should still return cached value
+ const second = getPageMeta('cached-meta');
+
+ expect(first).toBe('cached-value');
+ expect(second).toBe('cached-value');
+ });
+});
diff --git a/apps/meteor/client/lib/getPageMeta.ts b/apps/meteor/client/lib/getPageMeta.ts
new file mode 100644
index 0000000000000..02469afee9ae3
--- /dev/null
+++ b/apps/meteor/client/lib/getPageMeta.ts
@@ -0,0 +1,13 @@
+const MetaCache = new Map();
+export const getPageMeta = (name: string): string | null => {
+ if (typeof window === 'undefined') return null;
+ const cached = MetaCache.get(name);
+ if (cached !== undefined) return cached;
+ try {
+ const value = window.document?.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ?? null;
+ MetaCache.set(name, value);
+ return value;
+ } catch {
+ return null;
+ }
+};
diff --git a/apps/meteor/client/lib/normalizeThreadMessage.spec.tsx b/apps/meteor/client/lib/normalizeThreadMessage.spec.tsx
new file mode 100644
index 0000000000000..b1ceabeea3312
--- /dev/null
+++ b/apps/meteor/client/lib/normalizeThreadMessage.spec.tsx
@@ -0,0 +1,76 @@
+import { parse } from '@rocket.chat/message-parser';
+import { render } from '@testing-library/react';
+
+import { getMarkdownParserLimit } from './getMarkdownParserLimit';
+import { normalizeThreadMessage } from './normalizeThreadMessage';
+import { filterMarkdown } from '../../app/markdown/lib/markdown';
+
+jest.mock('./getMarkdownParserLimit');
+jest.mock('../../app/markdown/lib/markdown');
+jest.mock('@rocket.chat/message-parser');
+
+const mockedGetMarkdownParserLimit = jest.mocked(getMarkdownParserLimit);
+const mockedFilterMarkdown = jest.mocked(filterMarkdown);
+const mockedParse = jest.mocked(parse);
+
+describe('normalizeThreadMessage', () => {
+ beforeEach(() => {
+ mockedGetMarkdownParserLimit.mockReturnValue(Infinity);
+ mockedFilterMarkdown.mockImplementation((text) => text);
+ mockedParse.mockImplementation((text) => [{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: text }] }] as any);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should parse message through filterMarkdown and parse when within limit', () => {
+ const message = { msg: 'Hello world', mentions: [], attachments: [] };
+ normalizeThreadMessage(message);
+
+ expect(mockedFilterMarkdown).toHaveBeenCalledWith('Hello world');
+ expect(mockedParse).toHaveBeenCalledWith('Hello world', { emoticons: true });
+ });
+
+ it('should skip filterMarkdown and parse when message exceeds limit', () => {
+ mockedGetMarkdownParserLimit.mockReturnValue(5);
+
+ const message = { msg: 'This message is longer than the limit', mentions: [], attachments: [] };
+ const result = normalizeThreadMessage(message);
+
+ expect(mockedFilterMarkdown).not.toHaveBeenCalled();
+ expect(mockedParse).not.toHaveBeenCalled();
+
+ // Still renders (as plain text AST through Markup)
+ expect(result).not.toBeNull();
+ const { container } = render(<>{result}>);
+ expect(container.textContent).toContain('This message is longer than the limit');
+ });
+
+ it('should return null when msg is empty and no attachments', () => {
+ const message = { msg: '', mentions: [], attachments: undefined } as any;
+ expect(normalizeThreadMessage(message)).toBeNull();
+ });
+
+ it('should return attachment title when msg is empty but attachment has title', () => {
+ const message = { msg: '', mentions: [], attachments: [{ title: 'file.pdf' }] } as any;
+ const result = normalizeThreadMessage(message);
+
+ const { container } = render(<>{result}>);
+ expect(container.textContent).toBe('file.pdf');
+ });
+
+ it('should return null when msg is empty and attachments have no title', () => {
+ const message = { msg: '', mentions: [], attachments: [{ description: 'desc' }] } as any;
+ expect(normalizeThreadMessage(message)).toBeNull();
+ });
+
+ it('should return null when parse throws an error', () => {
+ mockedParse.mockImplementation(() => {
+ throw new Error('parse error');
+ });
+
+ const message = { msg: 'test', mentions: [], attachments: [] };
+ expect(normalizeThreadMessage(message)).toBeNull();
+ });
+});
diff --git a/apps/meteor/client/lib/normalizeThreadMessage.tsx b/apps/meteor/client/lib/normalizeThreadMessage.tsx
index 7f13803d2f80a..61f1ae289a05b 100644
--- a/apps/meteor/client/lib/normalizeThreadMessage.tsx
+++ b/apps/meteor/client/lib/normalizeThreadMessage.tsx
@@ -1,17 +1,32 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { Markup } from '@rocket.chat/gazzodown';
import { parse } from '@rocket.chat/message-parser';
+import type { Root } from '@rocket.chat/message-parser';
import type { ReactElement } from 'react';
+import { getMarkdownParserLimit } from './getMarkdownParserLimit';
import { filterMarkdown } from '../../app/markdown/lib/markdown';
import GazzodownText from '../components/GazzodownText';
+const tryParseWithLimit = (text: string): Root | undefined => {
+ if (text.length > getMarkdownParserLimit()) {
+ return [{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: text }] }] as Root;
+ }
+
+ const filtered = filterMarkdown(text);
+
+ try {
+ return parse(filtered, { emoticons: true });
+ } catch {
+ return undefined;
+ }
+};
+
export function normalizeThreadMessage({ ...message }: Readonly>): ReactElement | null {
if (message.msg) {
- message.msg = filterMarkdown(message.msg);
delete message.mentions;
- const tokens = message.msg ? parse(message.msg, { emoticons: true }) : undefined;
+ const tokens = tryParseWithLimit(message.msg);
if (!tokens) {
return null;
diff --git a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts
index 0105608949b7f..059f4e6e1bc07 100644
--- a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts
+++ b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts
@@ -1,8 +1,13 @@
import type { IMessage, ITranslatedMessage } from '@rocket.chat/core-typings';
import type { Options, Root } from '@rocket.chat/message-parser';
+import { getMarkdownParserLimit } from './getMarkdownParserLimit';
import { parseMessageAttachments, parseMessageTextToAstMarkdown } from './parseMessageTextToAstMarkdown';
+jest.mock('./getMarkdownParserLimit');
+
+const mockedGetMarkdownParserLimit = jest.mocked(getMarkdownParserLimit);
+
describe('parseMessageTextToAstMarkdown', () => {
const date = new Date('2021-10-27T00:00:00.000Z');
@@ -451,3 +456,88 @@ describe('parseMessageAttachments', () => {
});
});
});
+
+describe('parser limit handling', () => {
+ const parseOptions: Options = {
+ colors: true,
+ emoticons: true,
+ katex: {
+ dollarSyntax: true,
+ parenthesisSyntax: true,
+ },
+ };
+
+ const date = new Date('2021-10-27T00:00:00.000Z');
+
+ const baseMessage: IMessage = {
+ ts: date,
+ u: {
+ _id: 'userId',
+ name: 'userName',
+ username: 'userName',
+ },
+ msg: 'message **bold** _italic_ and ~strike~',
+ rid: 'roomId',
+ _id: 'messageId',
+ _updatedAt: date,
+ urls: [],
+ };
+
+ const autoTranslateOptions = {
+ autoTranslateEnabled: false,
+ showAutoTranslate: () => false,
+ };
+
+ beforeEach(() => {
+ mockedGetMarkdownParserLimit.mockReturnValue(Infinity);
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should return plain text AST when message exceeds parser limit', () => {
+ mockedGetMarkdownParserLimit.mockReturnValue(10);
+
+ const result = parseMessageTextToAstMarkdown(baseMessage, parseOptions, autoTranslateOptions);
+
+ expect(result.md).toStrictEqual([{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: baseMessage.msg }] }]);
+ });
+
+ it('should return plain text AST for attachment text exceeding parser limit', () => {
+ mockedGetMarkdownParserLimit.mockReturnValue(10);
+
+ const messageWithAttachment = {
+ ...baseMessage,
+ msg: 'short',
+ attachments: [{ text: 'this attachment text is definitely longer than the limit' }],
+ };
+
+ const result = parseMessageTextToAstMarkdown(messageWithAttachment, parseOptions, autoTranslateOptions);
+
+ expect(result.attachments?.[0].md).toStrictEqual([
+ { type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'this attachment text is definitely longer than the limit' }] },
+ ]);
+ });
+
+ it('should parse normally when message is within the limit', () => {
+ mockedGetMarkdownParserLimit.mockReturnValue(Infinity);
+
+ const result = parseMessageTextToAstMarkdown(baseMessage, parseOptions, autoTranslateOptions);
+
+ // Parsed result has BOLD, ITALIC, STRIKE — not just PLAIN_TEXT
+ expect(result.md).not.toStrictEqual([{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: baseMessage.msg }] }]);
+ expect(result.md[0].type).toBe('PARAGRAPH');
+ });
+
+ it('should return existing md when message already has md and is not translated', () => {
+ mockedGetMarkdownParserLimit.mockReturnValue(10);
+
+ const existingMd: Root = [{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'pre-parsed' }] }];
+ const messageWithMd = { ...baseMessage, md: existingMd };
+
+ const result = parseMessageTextToAstMarkdown(messageWithMd, parseOptions, autoTranslateOptions);
+
+ expect(result.md).toBe(existingMd);
+ });
+});
diff --git a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts
index 55d393cee38ed..1f8573dcbea54 100644
--- a/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts
+++ b/apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts
@@ -3,6 +3,7 @@ import { isE2EEMessage, isQuoteAttachment, isTranslatedAttachment, isTranslatedM
import type { Options, Root } from '@rocket.chat/message-parser';
import { parse } from '@rocket.chat/message-parser';
+import { getMarkdownParserLimit } from './getMarkdownParserLimit';
import type { AutoTranslateOptions } from '../views/room/MessageList/hooks/useAutoTranslate';
import { isParsedMessage } from '../views/room/MessageList/lib/isParsedMessage';
@@ -113,6 +114,11 @@ const textToMessageToken = (textOrRoot: string | Root, parseOptions: Options): R
if (isParsedMessage(textOrRoot)) {
return textOrRoot;
}
+
+ if (textOrRoot.length > getMarkdownParserLimit()) {
+ return [{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: textOrRoot }] }];
+ }
+
const parsedMessage = parse(textOrRoot, parseOptions);
const parsedMessageCleaned = parsedMessage[0].type !== 'LINE_BREAK' ? parsedMessage : (parsedMessage.slice(1) as Root);
diff --git a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx
index 4f8d7d27cf74c..374f348baa4a1 100644
--- a/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx
+++ b/apps/meteor/client/views/omnichannel/contactHistory/MessageList/ContactHistoryMessage.tsx
@@ -117,9 +117,9 @@ const ContactHistoryMessage = ({ message, sequential, isNewDay, showUserAvatar }
)}
{!!quotes?.length && }
- {!message.blocks && message.md && (
+ {!message.blocks && (
-
+ {message.md ? : message.msg}
)}
{message.blocks && }