Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/real-zoos-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@rocket.chat/federation-matrix': patch
'@rocket.chat/meteor': patch
---

Fixes federation endpoints rejecting valid requests, which broke:

- room history backfill
- image thumbnails
- room message pagination
- accepting an invite from another homeserver
5 changes: 5 additions & 0 deletions .changeset/wide-laws-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixes text rendering without line breaks when its length is higher than the value of the `MESSAGE_MAX_PARSE_LENGTH` environment variable
4 changes: 2 additions & 2 deletions apps/meteor/app/apps/server/bridges/livechat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ export class AppLivechatBridge extends LivechatBridge {
return this.orch
.getConverters()
?.get('visitors')
.convertVisitor(await LivechatVisitors.getVisitorByToken(token, {}));
.convertVisitor(await LivechatVisitors.getVisitorByToken<ILivechatVisitor>(token, {}));
}

protected async findVisitorByPhoneNumber(phoneNumber: string, appId: string): Promise<IVisitor | undefined> {
Expand Down Expand Up @@ -380,7 +380,7 @@ export class AppLivechatBridge extends LivechatBridge {
return this.orch
.getConverters()
?.get('departments')
.convertDepartment(await LivechatDepartment.findOneByIdOrName(value, {}));
.convertDepartment(await LivechatDepartment.findOneByIdOrName<ILivechatDepartment>(value, {}));
}

protected async findDepartmentsEnabledWithAgents(appId: string): Promise<Array<IDepartment>> {
Expand Down
15 changes: 14 additions & 1 deletion apps/meteor/client/components/MarkdownText.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';

Expand All @@ -16,10 +17,22 @@ type MarkdownTextParams = {

export type MarkdownTextProps = Partial<MarkdownTextParams>;

const preserveLineBreaks = css`
white-space: pre-line;
`;

const MarkdownText = ({ content, withTruncatedText = false, variant, preserveHtml, parseEmoji, ...boxProps }: MarkdownTextProps) => {
if (content && content.length > getMarkdownParserLimit()) {
// `document` parses with `breaks: true`, so its line breaks have to survive the unparsed
// fallback; the inline variants collapse them on purpose, and truncation needs a single line.
const keepLineBreaks = (variant ?? 'document') === 'document' && !withTruncatedText;

return (
<Box withTruncatedText={withTruncatedText} {...boxProps}>
<Box
withTruncatedText={withTruncatedText}
{...boxProps}
className={[boxProps.className, keepLineBreaks && preserveLineBreaks].flat()}
>
{content}
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import AttachmentContent from './structure/AttachmentContent';
import AttachmentDetails from './structure/AttachmentDetails';
import AttachmentInner from './structure/AttachmentInner';
import AttachmentMessageLink from './structure/AttachmentMessageLink';
import { toPlainTextRoot } from '../../../../lib/toPlainTextRoot';

// TODO: remove this team collaboration
const quoteStyles = css`
Expand Down Expand Up @@ -74,7 +75,7 @@ export const QuoteAttachment = ({ attachment, source }: QuoteAttachmentProps) =>
/>
</AttachmentInner>
)}
{attachment.md ? <MessageContentBody md={attachment.md} /> : attachment.text.substring(attachment.text.indexOf('\n') + 1)}
<MessageContentBody md={attachment.md ?? toPlainTextRoot(attachment.text)} />
</AttachmentDetails>
</AttachmentContent>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,29 @@ 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: Root | undefined = message.md && [...message.md];
const mdTokens = getMdTokens(message);
if (
message.attachments &&
Array.isArray(message.attachments) &&
Expand Down
20 changes: 19 additions & 1 deletion apps/meteor/client/lib/normalizeThreadMessage.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { parse } from '@rocket.chat/message-parser';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import type { TFunction } from 'i18next';

import { getMarkdownParserLimit } from './getMarkdownParserLimit';
Expand Down Expand Up @@ -52,6 +52,24 @@ describe('normalizeThreadMessage', () => {
expect(container.textContent).toContain('This message is longer than the limit');
});

it('should render one block per line when the message exceeds the limit', () => {
mockedGetMarkdownParserLimit.mockReturnValue(5);

const message = { msg: 'line one\nline two', mentions: [], attachments: [] } as unknown as IMessage;
const result = normalizeThreadMessage(message, t);

expect(mockedParse).not.toHaveBeenCalled();

render(<>{result}</>);

// `getByText` matches an element whose own text equals the query, so these only pass if each
// line got its own block. A single text node holding the `\n` would normalize to
// "line one line two" and neither query would match, which is exactly the collapsed rendering
// this guards against.
expect(screen.getByText('line one')).toBeInTheDocument();
expect(screen.getByText('line two')).toBeInTheDocument();
});

it('should return null when msg is empty and no attachments', () => {
const message = { msg: '', mentions: [], attachments: undefined } as unknown as IMessage;
expect(normalizeThreadMessage(message, t)).toBeNull();
Expand Down
3 changes: 2 additions & 1 deletion apps/meteor/client/lib/normalizeThreadMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { MessageTypes } from '@rocket.chat/message-types';
import type { TFunction } from 'i18next';

import { getMarkdownParserLimit } from './getMarkdownParserLimit';
import { toPlainTextRoot } from './toPlainTextRoot';
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;
return toPlainTextRoot(text);
}

const filtered = filterMarkdown(text);
Expand Down
31 changes: 31 additions & 0 deletions apps/meteor/client/lib/parseMessageTextToAstMarkdown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,4 +640,35 @@ describe('parser limit handling', () => {

expect(result.md).toBe(existingMd);
});

it('should return one block per line when the message exceeds the limit', () => {
mockedGetMarkdownParserLimit.mockReturnValue(10);

const result = parseMessageTextToAstMarkdown({ ...baseMessage, msg: 'line one\nline two' }, parseOptions, autoTranslateOptions);

expect(result.md).toStrictEqual([
{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'line one' }] },
{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'line two' }] },
]);
});

it('should keep blank lines as line breaks when the message exceeds the limit', () => {
mockedGetMarkdownParserLimit.mockReturnValue(10);

const result = parseMessageTextToAstMarkdown({ ...baseMessage, msg: 'line one\n\nline three' }, parseOptions, autoTranslateOptions);

expect(result.md).toStrictEqual([
{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'line one' }] },
{ type: 'LINE_BREAK', value: undefined },
{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'line three' }] },
]);
});

it('should drop a leading line break past the limit, as the parser path does', () => {
mockedGetMarkdownParserLimit.mockReturnValue(10);

const result = parseMessageTextToAstMarkdown({ ...baseMessage, msg: '\nline one\nline two' }, parseOptions, autoTranslateOptions);

expect(result.md[0]).toStrictEqual({ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: 'line one' }] });
});
});
7 changes: 2 additions & 5 deletions apps/meteor/client/lib/parseMessageTextToAstMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Options, Root } from '@rocket.chat/message-parser';
import { parse } from '@rocket.chat/message-parser';

import { getMarkdownParserLimit } from './getMarkdownParserLimit';
import { toPlainTextRoot } from './toPlainTextRoot';
import type { AutoTranslateOptions } from '../views/room/MessageList/hooks/useAutoTranslate';
import { isParsedMessage } from '../views/room/MessageList/lib/isParsedMessage';

Expand Down Expand Up @@ -136,11 +137,7 @@ const textToMessageToken = (textOrRoot: string | Root, parseOptions: Options): R
return textOrRoot;
}

if (textOrRoot.length > getMarkdownParserLimit()) {
return [{ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: textOrRoot }] }];
}

const parsedMessage = parse(textOrRoot, parseOptions);
const parsedMessage = textOrRoot.length > getMarkdownParserLimit() ? toPlainTextRoot(textOrRoot) : parse(textOrRoot, parseOptions);

const parsedMessageCleaned = parsedMessage[0].type !== 'LINE_BREAK' ? parsedMessage : (parsedMessage.slice(1) as Root);

Expand Down
72 changes: 72 additions & 0 deletions apps/meteor/client/lib/toPlainTextRoot.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { parse } from '@rocket.chat/message-parser';

import { toPlainTextRoot } from './toPlainTextRoot';

const paragraph = (value: string) => ({ type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value }] });
const lineBreak = { type: 'LINE_BREAK', value: undefined };

describe('toPlainTextRoot', () => {
it('should return an empty root for an empty string', () => {
expect(toPlainTextRoot('')).toEqual([]);
});

it('should wrap a single line into a paragraph', () => {
expect(toPlainTextRoot('hello')).toEqual([paragraph('hello')]);
});

it('should emit one paragraph per line', () => {
expect(toPlainTextRoot('line one\nline two')).toEqual([paragraph('line one'), paragraph('line two')]);
});

it('should emit a line break for a blank line', () => {
expect(toPlainTextRoot('line one\n\nline three')).toEqual([paragraph('line one'), lineBreak, paragraph('line three')]);
});

it('should emit consecutive line breaks for consecutive blank lines', () => {
expect(toPlainTextRoot('a\n\n\nb')).toEqual([paragraph('a'), lineBreak, lineBreak, paragraph('b')]);
});

it('should keep markdown syntax as literal text', () => {
expect(toPlainTextRoot('**bold** and _italic_')).toEqual([paragraph('**bold** and _italic_')]);
});

it('should keep mentions and emojis as literal text', () => {
expect(toPlainTextRoot('hey @rocket.cat :smile:')).toEqual([paragraph('hey @rocket.cat :smile:')]);
});

describe('line ending normalization', () => {
it('should normalize CRLF line endings', () => {
expect(toPlainTextRoot('line one\r\nline two')).toEqual([paragraph('line one'), paragraph('line two')]);
});

it('should normalize lone CR line endings', () => {
expect(toPlainTextRoot('line one\rline two')).toEqual([paragraph('line one'), paragraph('line two')]);
});
});

describe('trailing line breaks', () => {
it('should ignore a single trailing line break', () => {
expect(toPlainTextRoot('hello\n')).toEqual([paragraph('hello')]);
});

it('should keep a blank line before a trailing line break', () => {
expect(toPlainTextRoot('hello\n\n')).toEqual([paragraph('hello'), lineBreak]);
});

it('should keep a leading blank line', () => {
expect(toPlainTextRoot('\nhello')).toEqual([lineBreak, paragraph('hello')]);
});
});

// The whole point of the fallback is to render like the parser minus the markup, so for text that
// carries no syntax both must produce the same tree. If one of these ever fails, the parser is the
// source of truth and this helper is what should change.
describe('parity with the parser', () => {
it.each([['single line'], ['line one\nline two'], ['line one\n\nline three'], ['line one\n']])(
'should match the parser output for %j',
(text) => {
expect(toPlainTextRoot(text)).toEqual(parse(text));
},
);
});
});
27 changes: 27 additions & 0 deletions apps/meteor/client/lib/toPlainTextRoot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Root } from '@rocket.chat/message-parser';

/**
* Builds a `Root` that renders `text` verbatim.
*
* Line breaks are not part of the text a renderer receives: the parser turns them into structure
* (one `PARAGRAPH` per line, a `LINE_BREAK` per blank line), and HTML collapses any `\n` left inside
* a text node. So messages that skip parsing (the ones past `MESSAGE_MAX_PARSE_LENGTH`) have to be
* mapped to the same structure, or they render as a single line.
*/
export const toPlainTextRoot = (text: string): Root => {
if (!text) {
return [];
}

// `marked` and `message-parser` both normalize line endings before parsing; without this a message
// pasted from Windows keeps a stray `\r` at the end of every line.
const lines = text.replace(/\r\n?/g, '\n').split('\n');

if (lines.length > 1 && lines[lines.length - 1] === '') {
lines.pop();
}

return lines.map((line) =>
line ? { type: 'PARAGRAPH', value: [{ type: 'PLAIN_TEXT', value: line }] } : { type: 'LINE_BREAK', value: undefined },
) as Root;
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import UiKitMessageBlock from '../../../../components/message/uikit/UiKitMessage
import { useFormatDate } from '../../../../hooks/useFormatDate';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
import { useFormatTime } from '../../../../hooks/useFormatTime';
import { toPlainTextRoot } from '../../../../lib/toPlainTextRoot';
import MessageReportInfo from '../MessageReportInfo';
import useDeleteMessage from '../hooks/useDeleteMessage';
import { useDismissMessageAction } from '../hooks/useDismissMessageAction';
Expand Down Expand Up @@ -92,7 +93,9 @@ const ContextMessage = ({
{message.e2e === 'pending' && t('E2E_message_encrypted_placeholder')}
</>
) : (
message.msg
!!message.msg && (
<MessageContentBody md={toPlainTextRoot(message.msg)} mentions={message.mentions} channels={message.channels} />
)
)}

{!!attachments && <Attachments id={message.files?.[0]?._id} attachments={attachments} />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
Message as MessageTemplate,
MessageLeftContainer,
MessageContainer,
MessageBody,
MessageDivider,
MessageName,
MessageUsername,
Expand All @@ -30,6 +29,7 @@ import Attachments from '../../../../components/message/content/Attachments';
import UiKitMessageBlock from '../../../../components/message/uikit/UiKitMessageBlock';
import { useFormatDate } from '../../../../hooks/useFormatDate';
import { useFormatTime } from '../../../../hooks/useFormatTime';
import { toPlainTextRoot } from '../../../../lib/toPlainTextRoot';

export type ContactHistoryMessageProps = {
message: IMessage;
Expand Down Expand Up @@ -117,14 +117,14 @@ const ContactHistoryMessage = ({ message, sequential, isNewDay, showUserAvatar }
</MessageHeaderTemplate>
)}
{!!quotes?.length && <Attachments attachments={quotes} />}
{!message.blocks &&
(message.md ? (
<MessageContentBody data-qa-type='message-body' md={message.md} mentions={message.mentions} channels={message.channels} />
) : (
<MessageBody data-qa-type='message-body' dir='auto'>
{message.msg}
</MessageBody>
))}
{!message.blocks && (
<MessageContentBody
data-qa-type='message-body'
md={message.md ?? toPlainTextRoot(message.msg)}
mentions={message.mentions}
channels={message.channels}
/>
)}
{message.blocks && <UiKitMessageBlock rid={message.rid} mid={message._id} blocks={message.blocks} />}
{!!attachments && <Attachments attachments={attachments} />}
</MessageContainer>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useMemo } from 'react';
import { useAutoLinkDomains } from './useAutoLinkDomains';
import { useMessageListAutoTranslate } from '../../../../components/message/list/MessageListContext';
import { parseMessageTextToAstMarkdown } from '../../../../lib/parseMessageTextToAstMarkdown';
import { toPlainTextRoot } from '../../../../lib/toPlainTextRoot';

export const useMessageBody = (message: IMessage | undefined): string | Root => {
const autoTranslateOptions = useMessageListAutoTranslate();
Expand All @@ -27,7 +28,7 @@ export const useMessageBody = (message: IMessage | undefined): string | Root =>
}

if (message.msg) {
return message.msg;
return toPlainTextRoot(message.msg);
}

if (message.attachments) {
Expand Down
Loading
Loading