Skip to content
Open
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
53 changes: 53 additions & 0 deletions app/containers/MessageComposer/components/Quotes/Quote.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { render } from '@testing-library/react-native';

import { Quote } from './Quote';

const mockMessage: {
msg: string;
channels?: { _id: string; name: string; fname?: string }[];
u: { username: string };
id: string;
} = {
id: 'm1',
msg: 'see #2P3ydWKGPhoXrbxJL',
channels: [{ _id: 'r1', name: '2P3ydWKGPhoXrbxJL', fname: 'My Discussion' }],
u: { username: 'alice' }
};

jest.mock('../../hooks', () => ({
useMessage: jest.fn(() => mockMessage)
}));

jest.mock('../../../../views/RoomView/context', () => ({
useRoomContext: jest.fn(() => ({ onRemoveQuoteMessage: jest.fn() }))
}));

jest.mock('../../../../lib/hooks/useAppSelector', () => ({
useAppSelector: jest.fn(() => false)
}));

// Pulls in the composer store, which is irrelevant to what this test asserts
jest.mock('../Buttons', () => ({
BaseButton: () => null
}));

describe('composer Quote preview', () => {
beforeEach(() => {
mockMessage.channels = [{ _id: 'r1', name: '2P3ydWKGPhoXrbxJL', fname: 'My Discussion' }];
});

it('shows the discussion mention by fname instead of the room id', () => {
const { queryByText } = render(<Quote messageId='m1' />);

expect(queryByText('see #2P3ydWKGPhoXrbxJL')).toBeNull();
expect(queryByText('see #My Discussion')).toBeTruthy();
});

it('leaves the text alone when the message has no channels', () => {
mockMessage.channels = undefined;

const { queryByText } = render(<Quote messageId='m1' />);

expect(queryByText('see #2P3ydWKGPhoXrbxJL')).toBeTruthy();
});
});
2 changes: 1 addition & 1 deletion app/containers/MessageComposer/components/Quotes/Quote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const Quote = ({ messageId }: { messageId: string }) => {
testID={`composer-quote-remove-${message.id}`}
/>
</View>
<MarkdownPreview style={styles.message} numberOfLines={1} msg={msg} />
<MarkdownPreview style={styles.message} numberOfLines={1} msg={msg} channels={message.channels} />
</View>
);
};
Expand Down
22 changes: 22 additions & 0 deletions app/containers/markdown/Markdown.textStyle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,26 @@ describe('Markdown textStyle integration', () => {

expect(onLinkPress).toHaveBeenCalledWith('https://rocket.chat');
});

it('renders a channel mention using fname while still matching the token by name', () => {
const navToRoomInfo = jest.fn();

const { getByText, queryByText } = render(
<Markdown
msg='see #aBcD123xyz'
channels={[{ _id: 'r1', name: 'aBcD123xyz', fname: 'My Discussion' }]}
navToRoomInfo={navToRoomInfo}
/>
);

// `roomsWithHashTagSymbol` is mocked on, hence the leading `#`
expect(getByText('#My Discussion')).toBeTruthy();
expect(queryByText('#aBcD123xyz')).toBeNull();
});

it('falls back to the raw token when the channel has no fname', () => {
const { getByText } = render(<Markdown msg='#general' channels={[{ _id: 'r1', name: 'general' }]} />);

expect(getByText('#general')).toBeTruthy();
});
});
45 changes: 45 additions & 0 deletions app/containers/markdown/MarkdownPreview.channels.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { render } from '@testing-library/react-native';

import { MarkdownPreview } from '.';

jest.mock('../../lib/hooks/useAppSelector', () => ({
useAppSelector: jest.fn(() => false)
}));

jest.mock('../../lib/methods/userPreferences', () => ({
useUserPreferences: jest.fn(() => [true])
}));

// Previews (composer quote, sent quote, thread previews) render plain text rather than the
// markdown tree, so they resolve discussion mentions through `channels` themselves.
describe('MarkdownPreview channel mentions', () => {
it('shows a discussion mention by its fname instead of the room id', () => {
const { queryByText } = render(
<MarkdownPreview msg='see #aBcD123xyz' channels={[{ _id: 'r1', name: 'aBcD123xyz', fname: 'My Discussion' }]} />
);

expect(queryByText('see #My Discussion')).toBeTruthy();
expect(queryByText('see #aBcD123xyz')).toBeNull();
});

it('resolves a mention that is the entire message', () => {
const { queryByText } = render(
<MarkdownPreview msg='#aBcD123xyz' channels={[{ _id: 'r1', name: 'aBcD123xyz', fname: 'My Discussion' }]} />
);

expect(queryByText('#aBcD123xyz')).toBeNull();
expect(queryByText('#My Discussion')).toBeTruthy();
});

it('leaves a regular channel mention untouched', () => {
const { queryByText } = render(<MarkdownPreview msg='see #general' channels={[{ _id: 'r1', name: 'general' }]} />);

expect(queryByText('see #general')).toBeTruthy();
});

it('renders unchanged when no channels are supplied', () => {
const { queryByText } = render(<MarkdownPreview msg='see #aBcD123xyz' />);

expect(queryByText('see #aBcD123xyz')).toBeTruthy();
});
});
6 changes: 4 additions & 2 deletions app/containers/markdown/components/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@ import { type StyleProp, Text, type TextStyle } from 'react-native';
import { themes } from '../../../lib/constants/colors';
import { useTheme } from '../../../theme';
import usePreviewFormatText from '../../../lib/hooks/usePreviewFormatText';
import { type IUserChannel } from '../interfaces';
import styles from '../styles';

interface IMarkdownPreview {
msg?: string;
numberOfLines?: number;
testID?: string;
style?: StyleProp<TextStyle>;
channels?: IUserChannel[];
}

const MarkdownPreview = ({ msg, numberOfLines = 1, style = [], testID }: IMarkdownPreview) => {
const MarkdownPreview = ({ msg, numberOfLines = 1, style = [], testID, channels }: IMarkdownPreview) => {
const { theme } = useTheme();
const formattedText = usePreviewFormatText(msg ?? '');
const formattedText = usePreviewFormatText(msg ?? '', channels);

if (!msg) {
return null;
Expand Down
10 changes: 5 additions & 5 deletions app/containers/markdown/components/mentions/Hashtag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ const Hashtag = memo(({ hashtag }: IHashtag) => {
const [roomsWithHashTagSymbol] = useUserPreferences<boolean>(ROOM_MENTIONS_PREFERENCES_KEY, false);
const isMasterDetail = useMasterDetail();
const preffix = roomsWithHashTagSymbol ? '#' : '';
const channel = channels?.find(({ name }) => name === hashtag);
const handlePress = async () => {
const index = channels?.findIndex(channel => channel.name === hashtag);
if (typeof index !== 'undefined' && navToRoomInfo) {
if (channel && navToRoomInfo) {
const navParam = {
t: 'c',
rid: channels?.[index]._id
rid: channel._id
};
const room = navParam.rid && (await getSubscriptionByRoomId(navParam.rid));
if (room) {
Expand All @@ -49,7 +49,7 @@ const Hashtag = memo(({ hashtag }: IHashtag) => {
}
};

if (channels && channels.length && channels.findIndex(channel => channel.name === hashtag) !== -1) {
if (channel) {
return (
<Text
style={[
Expand All @@ -60,7 +60,7 @@ const Hashtag = memo(({ hashtag }: IHashtag) => {
}
]}
onPress={handlePress}>
{`${preffix}${hashtag}`}
{`${preffix}${channel?.fname || hashtag}`}
</Text>
);
}
Expand Down
1 change: 1 addition & 0 deletions app/containers/markdown/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface IUserMention {
export interface IUserChannel {
name: string;
_id: string;
fname?: string;
}

export type TOnLinkPress = (link: string) => void;
6 changes: 5 additions & 1 deletion app/containers/message/components/Attachments/Reply.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { Attachments } from './components';
import Quote from './Quote';
import { useBaseUrl, useMessageUser, useTimeFormat } from '../../stores/MessageRoomStore';
import { useIsEncrypted, useMessageId } from '../../stores/MessageStore';
import { useQuotedMessageChannels } from '../../hooks/useQuotedMessageChannels';
import { formatChannelMentions } from '../../../../lib/methods/helpers/formatChannelMentions';
import MessageActionTouchable from '../Touchable/MessageActionTouchable';
import messageStyles from '../../styles';
import dayjs from '../../../../lib/dayjs';
Expand Down Expand Up @@ -106,6 +108,8 @@ const Title = ({ attachment }: { attachment: IAttachment }) => {
const Description = ({ attachment }: { attachment: IAttachment }) => {
const user = useMessageUser();
const text = attachment.text || attachment.title;
// Only quotes whose text can contain a channel mention need the quoted message's channels
const channels = useQuotedMessageChannels(attachment.message_link, text?.includes('#') ?? false);

if (!text) {
return null;
Expand All @@ -121,7 +125,7 @@ const Description = ({ attachment }: { attachment: IAttachment }) => {
return <MarkdownPreview msg={text} numberOfLines={0} />;
}

return <Markdown msg={text} username={user?.username} />;
return <Markdown msg={formatChannelMentions(text, channels)} username={user?.username} />;
};

const UrlImage = ({ image }: { image?: string }) => {
Expand Down
5 changes: 3 additions & 2 deletions app/containers/message/components/Content/PreviewContent.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { MarkdownPreview } from '../../../markdown';
import { getPreviewMessageFromAttachment } from '../../utils';
import { useAttachments, useMessageText } from '../../stores/MessageStore';
import { useAttachments, useMarkdownData, useMessageText } from '../../stores/MessageStore';
import { useAutoTranslate } from '../../stores/MessageRoomStore';
import ContentWrapper from './ContentWrapper';

const PreviewContent = () => {
const { messageText } = useMessageText();
const attachments = useAttachments();
const { channels } = useMarkdownData();
const { autoTranslateLanguage } = useAutoTranslate();

const previewMsg =
Expand All @@ -18,7 +19,7 @@ const PreviewContent = () => {

return (
<ContentWrapper>
<MarkdownPreview testID={`message-preview-${previewMsg}`} msg={previewMsg} />
<MarkdownPreview testID={`message-preview-${previewMsg}`} msg={previewMsg} channels={channels} />
</ContentWrapper>
);
};
Expand Down
38 changes: 38 additions & 0 deletions app/containers/message/components/__tests__/Reply.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../../../lib/constants/keys';
import { fileDownloadAndPreview } from '../../../../lib/methods/helpers';
import openLink from '../../../../lib/methods/helpers/openLink';
import { formatAttachmentUrl } from '../../../../lib/methods/helpers/formatAttachmentUrl';
import { getMessageById } from '../../../../lib/database/services/Message';

jest.mock('../../../markdown', () => {
const React = require('react');
Expand All @@ -21,6 +22,10 @@ jest.mock('../../../markdown', () => {
};
});

jest.mock('../../../../lib/database/services/Message', () => ({
getMessageById: jest.fn(() => Promise.resolve(null))
}));

jest.mock('../../../../lib/methods/helpers', () => ({
fileDownloadAndPreview: jest.fn(() => Promise.resolve())
}));
Expand All @@ -44,6 +49,7 @@ jest.mock('expo-image', () => {
const mockFileDownloadAndPreview = fileDownloadAndPreview as jest.Mock;
const mockOpenLink = openLink as jest.Mock;
const mockFormatAttachmentUrl = formatAttachmentUrl as jest.Mock;
const mockGetMessageById = getMessageById as jest.Mock;

const buildItem = (isEncrypted?: boolean) =>
({
Expand Down Expand Up @@ -84,6 +90,38 @@ describe('Reply', () => {
beforeEach(() => {
jest.clearAllMocks();
mockFormatAttachmentUrl.mockImplementation((url: string) => `formatted:${url}`);
mockGetMessageById.mockResolvedValue(null);
});

// A quote attachment carries the quoted text but not its channels, so they are read back off the
// cached message. The label is plain text: a quote is not a live, tappable mention.
describe('quoted discussion mentions', () => {
const quote: IAttachment = {
author_name: 'Alice',
text: 'see #aBcD123xyz',
message_link: 'https://open.rocket.chat/channel/general?msg=quoted-1'
};

it('renders the discussion name instead of the room id', async () => {
mockGetMessageById.mockResolvedValue({ channels: [{ _id: 'c1', name: 'aBcD123xyz', fname: 'My Discussion' }] });

const { getByTestId } = renderReply({ attachment: quote });

await waitFor(() => expect(getByTestId('reply-markdown')).toHaveTextContent('see #My Discussion'));
});

it('keeps the room id when the quoted message is not cached', async () => {
const { getByTestId } = renderReply({ attachment: quote });

await waitFor(() => expect(mockGetMessageById).toHaveBeenCalledWith('quoted-1'));
expect(getByTestId('reply-markdown')).toHaveTextContent('see #aBcD123xyz');
});

it('does not hit the database when the quoted text has no mention', () => {
renderReply({ attachment: { ...quote, text: 'plain quoted text' } });

expect(mockGetMessageById).not.toHaveBeenCalled();
});
});

describe('null gate', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,33 @@ describe('useMessageAccessibilityLabel', () => {
).toBe(`alice ${HOUR} hey alice check general.`);
});

// The screen shows the fname, so the label has to match it rather than announcing the room id
it('announces a discussion mention by its fname', () => {
expect(
renderLabel(
buildItem({
msg: 'see #aBcD123xyz',
channels: [{ _id: 'c1', name: 'aBcD123xyz', fname: 'My Discussion' }]
})
)
).toBe(`alice ${HOUR} see My Discussion.`);
});

// A shorter channel name must not eat the start of a longer mention
it('announces the whole mention when another channel name is a prefix of it', () => {
expect(
renderLabel(
buildItem({
msg: 'see #abcdef',
channels: [
{ _id: 'c1', name: 'abc', fname: 'Short' },
{ _id: 'c2', name: 'abcdef', fname: 'Long' }
]
})
)
).toBe(`alice ${HOUR} see Long.`);
});

it('appends "Message was read" when read receipts are enabled and the message is read', () => {
expect(renderLabel(buildItem({ unread: false }), { isReadReceiptEnabled: true })).toBe(
`alice ${HOUR} hello world. Message was read`
Expand Down
Loading
Loading