Skip to content

fix: Quote chaining in E2EE room not limiting quote depth #36143

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 12, 2025
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
5 changes: 5 additions & 0 deletions .changeset/breezy-otters-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/meteor": patch
---

Fixes message's quote chain limit not being respected for End-to-end encrypted rooms.
3 changes: 2 additions & 1 deletion apps/meteor/app/e2e/client/rocketchat.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { getMessageUrlRegex } from '../../../lib/getMessageUrlRegex';
import { isTruthy } from '../../../lib/isTruthy';
import { Rooms, Subscriptions, Messages } from '../../models/client';
import { settings } from '../../settings/client';
import { limitQuoteChain } from '../../ui-message/client/messageBox/limitQuoteChain';
import { getUserAvatarURL } from '../../utils/client';
import { sdk } from '../../utils/client/lib/SDKClient';
import { t } from '../../utils/lib/i18n';
Expand Down Expand Up @@ -785,7 +786,7 @@ class E2E extends Emitter {
getUserAvatarURL(decryptedQuoteMessage.u.username || '') as string,
);

message.attachments.push(quoteAttachment);
message.attachments.push(limitQuoteChain(quoteAttachment, settings.get('Message_QuoteChainLimit') ?? 2));
}),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import type { IMessage } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import { Accounts } from 'meteor/accounts-base';

import { limitQuoteChain } from './limitQuoteChain';
import type { FormattingButton } from './messageBoxFormatting';
import { formattingButtons } from './messageBoxFormatting';
import type { ComposerAPI } from '../../../../client/lib/chats/ChatAPI';
import { withDebouncing } from '../../../../lib/utils/highOrderFunctions';

export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string): ComposerAPI => {
export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string, quoteChainLimit: number): ComposerAPI => {
const triggerEvent = (input: HTMLTextAreaElement, evt: string): void => {
const event = new Event(evt, { bubbles: true });
// TODO: Remove this hack for react to trigger onChange
Expand Down Expand Up @@ -108,7 +109,7 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string)
};

const quoteMessage = async (message: IMessage): Promise<void> => {
_quotedMessages = [..._quotedMessages.filter((_message) => _message._id !== message._id), message];
_quotedMessages = [..._quotedMessages.filter((_message) => _message._id !== message._id), limitQuoteChain(message, quoteChainLimit)];
notifyQuotedMessagesUpdate();
input.focus();
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { faker } from '@faker-js/faker';
import { isQuoteAttachment } from '@rocket.chat/core-typings';
import type { AtLeast, IMessage, MessageAttachment, MessageQuoteAttachment } from '@rocket.chat/core-typings';

import { limitQuoteChain } from './limitQuoteChain';

class TestAttachment {}

const makeQuote = (): MessageQuoteAttachment => {
return {
text: `[ ](http://localhost:3000/group/encrypted?msg=${faker.string.uuid()})`,
message_link: `http://localhost:3000/group/encrypted?msg=${faker.string.uuid()}`,
author_name: faker.internet.userName(),
author_icon: `/avatar/${faker.internet.userName()}`,
author_link: `http://localhost:3000/group/encrypted?msg=${faker.string.uuid()}`,
};
};

const makeChainedQuote = (chainSize: number): MessageQuoteAttachment => {
if (chainSize === 0) {
throw new Error('Chain size cannot be 0');
}

if (chainSize === 1) {
return makeQuote();
}

return {
...makeQuote(),
attachments: [
makeChainedQuote(chainSize - 1),
// add a few extra attachments to ensure they are not messed with
new TestAttachment() as MessageAttachment,
new TestAttachment() as MessageAttachment,
],
};
};

const makeMessage = (chainSize: number): any => {
if (chainSize === 0) {
return {};
}

return {
attachments: [makeChainedQuote(chainSize)],
};
};

const chainSizes = [0, 1, 2, 3, 4, 5, 10, 20, 50, 100];

const limits = [0, 1, 2, 3, 4, 5, 10, 20, 50, 100];

const countQuoteDeepnes = (attachments?: MessageAttachment[], quoteLength = 0): number => {
if (!attachments || attachments.length < 1) {
return quoteLength + 1;
}

// Ensure sibling attachments didn't change
attachments.forEach((attachment) => {
if (!isQuoteAttachment(attachment) && !(attachment instanceof TestAttachment)) {
throw new Error('PANIC! Non quote attachment changed. This should never happen.');
}
});

const quote = attachments.find((attachment) => isQuoteAttachment(attachment));
if (!quote?.attachments) {
return quoteLength + 1;
}

return countQuoteDeepnes(quote.attachments, quoteLength + 1);
};

const countMessageQuoteChain = (message: AtLeast<IMessage, 'attachments'>) => {
return countQuoteDeepnes(message.attachments);
};

describe('ChatMessages Composer API - limitQuoteChain', () => {
describe.each(chainSizes)('quote chain %i levels deep', (size) => {
it.each(limits)('should limit chain to be at max %i level(s) deep', (limit) => {
// If the size is less than the limit, it shouldn't filter anything
// The main message also counts as a quote, so this will never be less than 1. See implementation for more details.
const quoteLimitOrSize = Math.max(1, Math.min(limit, size));
const message = makeMessage(size);
const limitedMessage = limitQuoteChain(message, limit);

expect(countMessageQuoteChain(limitedMessage)).toBe(quoteLimitOrSize);
});
});
});
38 changes: 38 additions & 0 deletions apps/meteor/app/ui-message/client/messageBox/limitQuoteChain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { isQuoteAttachment } from '@rocket.chat/core-typings';
import type { IMessage, MessageAttachment, AtLeast } from '@rocket.chat/core-typings';

// Observation:
// Currently, if the limit is 0, one quote is still allowed.
// This behavior is defined in the server side, so to keep things consistent, we'll keep it that way.
// See @createAttachmentForMessageURLs in @BeforeSaveJumpToMessage.ts
export const limitQuoteChain = <TMessage extends AtLeast<IMessage, 'attachments'>>(message: TMessage, limit = 2): TMessage => {
if (!Array.isArray(message.attachments) || message.attachments.length === 0) {
return message;
}

return {
...message,
attachments: traverseMessageQuoteChain(message.attachments, limit),
};
};

const traverseMessageQuoteChain = (attachments: MessageAttachment[], limit: number, currentLevel = 1): MessageAttachment[] => {
// read observation above.
if (limit < 2 || currentLevel >= limit) {
return attachments.filter((attachment) => !isQuoteAttachment(attachment));
}

return attachments.map((attachment) => {
if (isQuoteAttachment(attachment)) {
if (!attachment.attachments) {
return attachment;
}
return {
...attachment,
attachments: traverseMessageQuoteChain(attachment.attachments, limit, currentLevel + 1),
};
}

return attachment;
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ const MessageBox = ({
const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages', false);
const isSlashCommandAllowed = !e2eEnabled || !room.encrypted || unencryptedMessagesAllowed;
const composerPlaceholder = useMessageBoxPlaceholder(t('Message'), room);

const quoteChainLimit = useSetting('Message_QuoteChainLimit', 2);
const [typing, setTyping] = useReducer(reducer, false);

const { isMobile } = useLayout();
Expand All @@ -137,9 +137,9 @@ const MessageBox = ({
if (chat.composer) {
return;
}
chat.setComposerAPI(createComposerAPI(node, storageID));
chat.setComposerAPI(createComposerAPI(node, storageID, quoteChainLimit));
},
[chat, storageID],
[chat, storageID, quoteChainLimit],
);

const autofocusRef = useMessageBoxAutoFocus(!isMobile);
Expand Down
1 change: 1 addition & 0 deletions apps/meteor/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default {
testMatch: [
'<rootDir>/client/**/**.spec.[jt]s?(x)',
'<rootDir>/ee/client/**/**.spec.[jt]s?(x)',
'<rootDir>/app/ui-message/client/**/**.spec.[jt]s?(x)',
'<rootDir>/tests/unit/client/views/**/*.spec.{ts,tsx}',
'<rootDir>/tests/unit/client/providers/**/*.spec.{ts,tsx}',
],
Expand Down
Loading