Skip to content
6 changes: 6 additions & 0 deletions .changeset/sparkly-regions-drive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/message-types': patch
'@rocket.chat/meteor': patch
---

Fixes an issue where delete thread message display as empty in thread context
Comment thread
dougfabris marked this conversation as resolved.
Resolved
Comment thread
dougfabris marked this conversation as resolved.
Resolved
50 changes: 38 additions & 12 deletions apps/meteor/client/lib/normalizeThreadMessage.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { IMessage } from '@rocket.chat/core-typings';
import { parse } from '@rocket.chat/message-parser';
import { render } from '@testing-library/react';
import type { TFunction } from 'i18next';

import { getMarkdownParserLimit } from './getMarkdownParserLimit';
import { normalizeThreadMessage } from './normalizeThreadMessage';
Expand All @@ -14,6 +16,8 @@ const mockedGetMarkdownParserLimit = jest.mocked(getMarkdownParserLimit);
const mockedFilterMarkdown = jest.mocked(filterMarkdown);
const mockedParse = jest.mocked(parse);

const t = ((key: string) => key) as TFunction;

describe('normalizeThreadMessage', () => {
beforeEach(() => {
mockedGetMarkdownParserLimit.mockReturnValue(Infinity);
Expand All @@ -26,8 +30,8 @@ describe('normalizeThreadMessage', () => {
});

it('should parse message through filterMarkdown and parse when within limit', () => {
const message = { msg: 'Hello world', mentions: [], attachments: [] };
normalizeThreadMessage(message);
const message = { msg: 'Hello world', mentions: [], attachments: [] } as unknown as IMessage;
normalizeThreadMessage(message, t);

expect(mockedFilterMarkdown).toHaveBeenCalledWith('Hello world');
expect(mockedParse).toHaveBeenCalledWith('Hello world', { emoticons: true });
Expand All @@ -36,8 +40,8 @@ describe('normalizeThreadMessage', () => {
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);
const message = { msg: 'This message is longer than the limit', mentions: [], attachments: [] } as unknown as IMessage;
const result = normalizeThreadMessage(message, t);

expect(mockedFilterMarkdown).not.toHaveBeenCalled();
expect(mockedParse).not.toHaveBeenCalled();
Expand All @@ -49,21 +53,21 @@ describe('normalizeThreadMessage', () => {
});

it('should return null when msg is empty and no attachments', () => {
const message = { msg: '', mentions: [], attachments: undefined } as any;
expect(normalizeThreadMessage(message)).toBeNull();
const message = { msg: '', mentions: [], attachments: undefined } as unknown as IMessage;
expect(normalizeThreadMessage(message, t)).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 message = { msg: '', mentions: [], attachments: [{ title: 'file.pdf' }] } as unknown as IMessage;
const result = normalizeThreadMessage(message, t);

const { container } = render(<>{result}</>);
expect(container.textContent).toBe('file.pdf');
});

it('should return attachment description when msg is empty and attachments have no title', () => {
const message = { msg: '', mentions: [], attachments: [{ description: 'desc' }] } as any;
const result = normalizeThreadMessage(message);
const message = { msg: '', mentions: [], attachments: [{ description: 'desc' }] } as unknown as IMessage;
const result = normalizeThreadMessage(message, t);

const { container } = render(<>{result}</>);
expect(container.textContent).toBe('desc');
Expand All @@ -74,7 +78,29 @@ describe('normalizeThreadMessage', () => {
throw new Error('parse error');
});

const message = { msg: 'test', mentions: [], attachments: [] };
expect(normalizeThreadMessage(message)).toBeNull();
const message = { msg: 'test', mentions: [], attachments: [] } as unknown as IMessage;
expect(normalizeThreadMessage(message, t)).toBeNull();
});

it('should return the message type text for a removed message without content', () => {
const message = {
t: 'rm',
msg: '',
editedAt: new Date(),
editedBy: { _id: 'uid', username: 'user' },
mentions: [],
attachments: [],
} as unknown as IMessage;

expect(normalizeThreadMessage(message, t)).toBe('Message_HideType_rm');
expect(mockedFilterMarkdown).not.toHaveBeenCalled();
expect(mockedParse).not.toHaveBeenCalled();
});

it('should render the message content when a `t: rm` message still has content', () => {
const message = { t: 'rm', msg: 'Hello world', mentions: [], attachments: [] } as unknown as IMessage;

expect(normalizeThreadMessage(message, t)).not.toBe('Message_HideType_rm');
expect(mockedParse).toHaveBeenCalledWith('Hello world', { emoticons: true });
});
});
12 changes: 10 additions & 2 deletions apps/meteor/client/lib/normalizeThreadMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { IMessage } from '@rocket.chat/core-typings';
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 { MessageTypes } from '@rocket.chat/message-types';
import type { TFunction } from 'i18next';

import { getMarkdownParserLimit } from './getMarkdownParserLimit';
import { filterMarkdown } from '../../app/markdown/lib/markdown';
Expand All @@ -21,7 +23,9 @@ const tryParseWithLimit = (text: string): Root | undefined => {
}
};

export function normalizeThreadMessage({ ...message }: Readonly<Pick<IMessage, 'msg' | 'mentions' | 'attachments'>>) {
export function normalizeThreadMessage({ ...message }: Readonly<IMessage>, t: TFunction) {
const messageType = MessageTypes.getType(message);

if (message.msg) {
delete message.mentions;

Expand Down Expand Up @@ -50,5 +54,9 @@ export function normalizeThreadMessage({ ...message }: Readonly<Pick<IMessage, '
}
}

if (message.t) {
return messageType?.text(t, message, true);
}

return null;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { IDiscussionMessage } from '@rocket.chat/core-typings';
import type { MouseEvent } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';

import DiscussionsListItem from './components/DiscussionsListItem';
import { useTimeAgo } from '../../../../hooks/useTimeAgo';
Expand All @@ -13,9 +14,10 @@ export type DiscussionListRowProps = {
};

function DiscussionListRow({ discussion, showRealNames, onClick }: DiscussionListRowProps) {
const { t } = useTranslation();
const formatDate = useTimeAgo();

const msg = normalizeThreadMessage(discussion);
const msg = normalizeThreadMessage(discussion, t);

const { name = discussion.u.username } = discussion.u;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Palette } from '@rocket.chat/fuselage';
import { useSetting, useUserId } from '@rocket.chat/ui-contexts';
import type { MouseEvent } from 'react';
import { useCallback, memo } from 'react';
import { useTranslation } from 'react-i18next';

import ThreadListMessage from './ThreadListMessage';
import { useDecryptedMessage } from '../../../../../hooks/useDecryptedMessage';
Expand All @@ -18,9 +19,10 @@ export type ThreadListItemProps = {
};

const ThreadListItem = ({ thread, unread, unreadUser, unreadGroup, onClick }: ThreadListItemProps) => {
const { t } = useTranslation();
const uid = useUserId();
const decryptedMsg = useDecryptedMessage(thread);
const msg = normalizeThreadMessage({ ...thread, msg: decryptedMsg });
const msg = normalizeThreadMessage({ ...thread, msg: decryptedMsg }, t);

const { name = thread.u.username } = thread.u;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { IThreadMainMessage } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook } from '@testing-library/react';

import { useNormalizedThreadTitleHtml } from './useNormalizedThreadTitleHtml';
import { filterMarkdown } from '../../../../../../app/markdown/lib/markdown';

jest.mock('../../../../../../app/markdown/lib/markdown', () => ({
filterMarkdown: jest.fn((text: string) => text),
}));

jest.mock('../../../../../../app/emoji/client/emojiParser', () => ({
emojiParser: jest.fn((text: string) => text),
}));

const mockedFilterMarkdown = jest.mocked(filterMarkdown);

const baseMessage = {
_id: 'msg-id',
rid: 'rid',
ts: new Date(),
u: { _id: 'uid', username: 'user' },
_updatedAt: new Date(),
tcount: 1,
tlm: new Date(),
replies: [],
} as unknown as IThreadMainMessage;

describe('useNormalizedThreadTitleHtml', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('should return the "Message_HideType_rm" translation for a removed message without content', () => {
const message = {
...baseMessage,
t: 'rm',
msg: '',
editedAt: new Date(),
editedBy: { _id: 'uid', username: 'user' },
} as unknown as IThreadMainMessage;

const { result } = renderHook(() => useNormalizedThreadTitleHtml(message), {
wrapper: mockAppRoot().build(),
});

expect(result.current).toBe('Message_HideType_rm');
expect(mockedFilterMarkdown).not.toHaveBeenCalled();
});

it('should return the normalized message content for a regular message', () => {
const message = { ...baseMessage, msg: 'Hello world' } as unknown as IThreadMainMessage;

const { result } = renderHook(() => useNormalizedThreadTitleHtml(message), {
wrapper: mockAppRoot().build(),
});

expect(result.current).toBe('Hello world');
expect(mockedFilterMarkdown).toHaveBeenCalledWith('Hello world');
});

it('should render the message content when a `t: rm` message still has content', () => {
const message = { ...baseMessage, t: 'rm', msg: 'Hello world' } as unknown as IThreadMainMessage;

const { result } = renderHook(() => useNormalizedThreadTitleHtml(message), {
wrapper: mockAppRoot().build(),
});

expect(result.current).not.toBe('Message_HideType_rm');
expect(result.current).toBe('Hello world');
});
});
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import type { IThreadMainMessage } from '@rocket.chat/core-typings';
import { type IThreadMainMessage } from '@rocket.chat/core-typings';
import { MessageTypes } from '@rocket.chat/message-types';
import { escapeHTML } from '@rocket.chat/string-helpers';
import { useUser, useSetting } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import { emojiParser } from '../../../../../../app/emoji/client/emojiParser';
import { filterMarkdown } from '../../../../../../app/markdown/lib/markdown';
import { MentionsParser } from '../../../../../../app/mentions/lib/MentionsParser';

export const useNormalizedThreadTitleHtml = (mainMessage: IThreadMainMessage) => {
const { t } = useTranslation();
const me = useUser()?.username || '';
const pattern = useSetting('UTF8_User_Names_Validation', '[0-9a-zA-Z-_.]+');
const useRealName = useSetting('UI_Use_Real_Name', false);

return useMemo((): string => {
const message = { ...mainMessage };
const messageType = MessageTypes.getType(message);

if (message.msg) {
const filteredMessage = filterMarkdown(escapeHTML(message.msg));
Expand Down Expand Up @@ -44,6 +48,10 @@ export const useNormalizedThreadTitleHtml = (mainMessage: IThreadMainMessage) =>
}
}

if (message.t && messageType) {
return messageType.text(t, message, true);
}
Comment thread
dougfabris marked this conversation as resolved.
Resolved

return '';
}, [mainMessage, me, pattern, useRealName]);
}, [mainMessage, me, pattern, useRealName, t]);
};
2 changes: 1 addition & 1 deletion packages/message-types/src/MessageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { TFunction } from 'i18next';
type MessageType = {
readonly id: MessageTypesValues;
readonly system: boolean;
readonly text: (t: TFunction, message: IMessage) => string;
readonly text: (t: TFunction, message: IMessage, capitalize?: boolean) => string;
};

export class MessageTypes {
Expand Down
2 changes: 1 addition & 1 deletion packages/message-types/src/registrations/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export default (instance: MessageTypes) => {
instance.registerType({
id: 'rm',
system: true,
text: (t) => t('Message_is_removed'),
text: (t, _, capitalize) => (capitalize ? t('Message_HideType_rm') : t('Message_is_removed')),
});

instance.registerType({
Expand Down
Loading