diff --git a/.changeset/breezy-otters-mate.md b/.changeset/breezy-otters-mate.md new file mode 100644 index 0000000000000..4c6b80f77bc8b --- /dev/null +++ b/.changeset/breezy-otters-mate.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes message's quote chain limit not being respected for End-to-end encrypted rooms. diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index cbfc5f5578c03..0386250be2f15 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -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'; @@ -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)); }), ); diff --git a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts index eab54850f3bfb..6a4cb2b2254d4 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createComposerAPI.ts @@ -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 @@ -108,7 +109,7 @@ export const createComposerAPI = (input: HTMLTextAreaElement, storageID: string) }; const quoteMessage = async (message: IMessage): Promise => { - _quotedMessages = [..._quotedMessages.filter((_message) => _message._id !== message._id), message]; + _quotedMessages = [..._quotedMessages.filter((_message) => _message._id !== message._id), limitQuoteChain(message, quoteChainLimit)]; notifyQuotedMessagesUpdate(); input.focus(); }; diff --git a/apps/meteor/app/ui-message/client/messageBox/limitQuoteChain.spec.ts b/apps/meteor/app/ui-message/client/messageBox/limitQuoteChain.spec.ts new file mode 100644 index 0000000000000..f1499673d9d93 --- /dev/null +++ b/apps/meteor/app/ui-message/client/messageBox/limitQuoteChain.spec.ts @@ -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) => { + 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); + }); + }); +}); diff --git a/apps/meteor/app/ui-message/client/messageBox/limitQuoteChain.ts b/apps/meteor/app/ui-message/client/messageBox/limitQuoteChain.ts new file mode 100644 index 0000000000000..103bd3662bfbd --- /dev/null +++ b/apps/meteor/app/ui-message/client/messageBox/limitQuoteChain.ts @@ -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 = >(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; + }); +}; diff --git a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx index b7baf4600c830..227ceb92416a4 100644 --- a/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx @@ -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(); @@ -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); diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 677c3a06d28cd..f43130102daae 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -12,6 +12,7 @@ export default { testMatch: [ '/client/**/**.spec.[jt]s?(x)', '/ee/client/**/**.spec.[jt]s?(x)', + '/app/ui-message/client/**/**.spec.[jt]s?(x)', '/tests/unit/client/views/**/*.spec.{ts,tsx}', '/tests/unit/client/providers/**/*.spec.{ts,tsx}', ],