Skip to content

Commit 6674cc2

Browse files
feat: render Markdown within quoted message components (#2640)
### 🎯 Goal Currently, Markdown text is not being rendered properly within quoted messages (`renderText` omitted). This PR aims at adding `renderText` to both `QuotedMessage` and `QuotedMessagePreview` components. #### Missing - [x] CSS adjustment to mention `span` selector (must include message input too), [PR](GetStream/stream-chat-css#325) - [x] tests, [07fc7cf](07fc7cf) #### DO NOT MERGE BEFORE INSTALLING LATEST `@stream-io/stream-chat-css`
1 parent e58bc2a commit 6674cc2

File tree

7 files changed

+121
-25
lines changed

7 files changed

+121
-25
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@
187187
"@semantic-release/changelog": "^6.0.2",
188188
"@semantic-release/exec": "^6.0.3",
189189
"@semantic-release/git": "^10.0.1",
190-
"@stream-io/stream-chat-css": "^5.7.0",
190+
"@stream-io/stream-chat-css": "^5.7.1",
191191
"@testing-library/dom": "^10.4.0",
192192
"@testing-library/jest-dom": "^6.6.3",
193193
"@testing-library/react": "^16.2.0",

src/components/Message/QuotedMessage.tsx

+29-12
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,66 @@
1-
import React from 'react';
1+
import React, { useMemo } from 'react';
22
import clsx from 'clsx';
3+
import type { TranslationLanguages } from 'stream-chat';
34

45
import { Attachment as DefaultAttachment } from '../Attachment';
56
import { Avatar as DefaultAvatar } from '../Avatar';
67
import { Poll } from '../Poll';
7-
88
import { useChatContext } from '../../context/ChatContext';
99
import { useComponentContext } from '../../context/ComponentContext';
1010
import { useMessageContext } from '../../context/MessageContext';
1111
import { useTranslationContext } from '../../context/TranslationContext';
1212
import { useChannelActionContext } from '../../context/ChannelActionContext';
13+
import { renderText as defaultRenderText } from './renderText';
14+
import type { MessageContextValue } from '../../context/MessageContext';
1315

14-
import type { TranslationLanguages } from 'stream-chat';
16+
export type QuotedMessageProps<
17+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
18+
> = Pick<MessageContextValue<StreamChatGenerics>, 'renderText'>;
1519

1620
import type { DefaultStreamChatGenerics } from '../../types/types';
1721

1822
export const QuotedMessage = <
1923
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
20-
>() => {
24+
>({
25+
renderText: propsRenderText,
26+
}: QuotedMessageProps) => {
2127
const { Attachment = DefaultAttachment, Avatar: ContextAvatar } =
2228
useComponentContext<StreamChatGenerics>('QuotedMessage');
2329
const { client } = useChatContext();
24-
const { isMyMessage, message } = useMessageContext<StreamChatGenerics>('QuotedMessage');
30+
const {
31+
isMyMessage,
32+
message,
33+
renderText: contextRenderText,
34+
} = useMessageContext<StreamChatGenerics>('QuotedMessage');
2535
const { t, userLanguage } = useTranslationContext('QuotedMessage');
2636
const { jumpToMessage } = useChannelActionContext('QuotedMessage');
2737

38+
const renderText = propsRenderText ?? contextRenderText ?? defaultRenderText;
39+
2840
const Avatar = ContextAvatar || DefaultAvatar;
2941

3042
const { quoted_message } = message;
31-
if (!quoted_message) return null;
3243

33-
const poll = quoted_message.poll_id && client.polls.fromState(quoted_message.poll_id);
44+
const poll = quoted_message?.poll_id && client.polls.fromState(quoted_message.poll_id);
3445
const quotedMessageDeleted =
35-
quoted_message.deleted_at || quoted_message.type === 'deleted';
46+
quoted_message?.deleted_at || quoted_message?.type === 'deleted';
3647

3748
const quotedMessageText = quotedMessageDeleted
3849
? t('This message was deleted...')
39-
: quoted_message.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] ||
40-
quoted_message.text;
50+
: quoted_message?.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] ||
51+
quoted_message?.text;
4152

4253
const quotedMessageAttachment =
43-
quoted_message.attachments?.length && !quotedMessageDeleted
54+
quoted_message?.attachments?.length && !quotedMessageDeleted
4455
? quoted_message.attachments[0]
4556
: null;
4657

58+
const renderedText = useMemo(
59+
() => renderText(quotedMessageText, quoted_message?.mentioned_users),
60+
[quotedMessageText, quoted_message?.mentioned_users, renderText],
61+
);
62+
63+
if (!quoted_message) return null;
4764
if (!quoted_message.poll && !quotedMessageText && !quotedMessageAttachment) return null;
4865

4966
return (
@@ -80,7 +97,7 @@ export const QuotedMessage = <
8097
className='str-chat__quoted-message-bubble__text'
8198
data-testid='quoted-message-text'
8299
>
83-
{quotedMessageText}
100+
{renderedText}
84101
</div>
85102
</>
86103
)}

src/components/Message/__tests__/QuotedMessage.test.js

+38-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import '@testing-library/jest-dom';
22
import { render, screen } from '@testing-library/react';
33
import { toHaveNoViolations } from 'jest-axe';
44
import React from 'react';
5+
import { nanoid } from 'nanoid';
56
import { axe } from '../../../../axe-helper';
67

78
import {
@@ -106,13 +107,46 @@ describe('QuotedMessage', () => {
106107
expect(results).toHaveNoViolations();
107108
});
108109

109-
it('should rendered text', async () => {
110-
const { container, queryByTestId, queryByText } = await renderQuotedMessage({
110+
it('renders proper markdown (through default renderText fn)', async () => {
111+
const messageText = 'hey @John Cena';
112+
const { container, findByTestId, findByText, queryByTestId } =
113+
await renderQuotedMessage({
114+
customProps: {
115+
message: {
116+
quoted_message: {
117+
mentioned_users: [{ id: 'john', name: 'John Cena' }],
118+
text: messageText,
119+
},
120+
},
121+
},
122+
});
123+
124+
expect(await findByText('@John Cena')).toHaveAttribute('data-user-id');
125+
expect((await findByTestId('quoted-message-text')).textContent).toEqual(messageText);
126+
expect(queryByTestId(quotedAttachmentListTestId)).not.toBeInTheDocument();
127+
const results = await axe(container);
128+
expect(results).toHaveNoViolations();
129+
});
130+
131+
it('uses custom renderText fn if provided', async () => {
132+
const messageText = nanoid();
133+
const fn = jest
134+
.fn()
135+
.mockReturnValue(<div data-testid={messageText}>{messageText}</div>);
136+
137+
const { container, findByTestId, queryByTestId } = await renderQuotedMessage({
111138
customProps: {
112-
message: { quoted_message: { text: quotedText } },
139+
message: {
140+
quoted_message: {
141+
text: messageText,
142+
},
143+
},
144+
renderText: fn,
113145
},
114146
});
115-
expect(queryByText(quotedText)).toBeInTheDocument();
147+
148+
expect(fn).toHaveBeenCalled();
149+
expect((await findByTestId('quoted-message-text')).textContent).toEqual(messageText);
116150
expect(queryByTestId(quotedAttachmentListTestId)).not.toBeInTheDocument();
117151
const results = await axe(container);
118152
expect(results).toHaveNoViolations();

src/components/MessageInput/MessageInputFlat.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ export const MessageInputFlat = <
141141
const recordingEnabled = !!(recordingController.recorder && navigator.mediaDevices); // account for requirement on iOS as per this bug report: https://bugs.webkit.org/show_bug.cgi?id=252303
142142
const isRecording = !!recordingController.recordingState;
143143

144-
/* This bit here is needed to make sure that we can get rid of the default behaviour
144+
/**
145+
* This bit here is needed to make sure that we can get rid of the default behaviour
145146
* if need be. Essentially this allows us to pass StopAIGenerationButton={null} and
146147
* completely circumvent the default logic if it's not what we want. We need it as a
147148
* prop because there is no other trivial way to override the SendMessage button otherwise.

src/components/MessageInput/QuotedMessagePreview.tsx

+10-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import { useTranslationContext } from '../../context/TranslationContext';
1212

1313
import type { TranslationLanguages } from 'stream-chat';
1414
import type { StreamMessage } from '../../context/ChannelStateContext';
15+
import type { MessageContextValue } from '../../context';
1516
import type { DefaultStreamChatGenerics } from '../../types/types';
17+
import { renderText as defaultRenderText } from '../Message';
1618

1719
export const QuotedMessagePreviewHeader = <
1820
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
@@ -41,12 +43,14 @@ export type QuotedMessagePreviewProps<
4143
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
4244
> = {
4345
quotedMessage: StreamMessage<StreamChatGenerics>;
46+
renderText?: MessageContextValue<StreamChatGenerics>['renderText'];
4447
};
4548

4649
export const QuotedMessagePreview = <
4750
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
4851
>({
4952
quotedMessage,
53+
renderText = defaultRenderText,
5054
}: QuotedMessagePreviewProps<StreamChatGenerics>) => {
5155
const { client } = useChatContext();
5256
const { Attachment = DefaultAttachment, Avatar = DefaultAvatar } =
@@ -57,6 +61,11 @@ export const QuotedMessagePreview = <
5761
quotedMessage.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] ||
5862
quotedMessage.text;
5963

64+
const renderedText = useMemo(
65+
() => renderText(quotedMessageText, quotedMessage.mentioned_users),
66+
[quotedMessage.mentioned_users, quotedMessageText, renderText],
67+
);
68+
6069
const quotedMessageAttachment = useMemo(() => {
6170
const [attachment] = quotedMessage.attachments ?? [];
6271
return attachment ? [attachment] : [];
@@ -91,7 +100,7 @@ export const QuotedMessagePreview = <
91100
className='str-chat__quoted-message-text'
92101
data-testid='quoted-message-text'
93102
>
94-
<p>{quotedMessageText}</p>
103+
{renderedText}
95104
</div>
96105
</>
97106
)}

src/components/MessageInput/__tests__/MessageInput.test.js

+37-2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
initClientWithChannels,
2727
} from '../../../mock-builders';
2828
import { generatePoll } from '../../../mock-builders/generator/poll';
29+
import { QuotedMessagePreview } from '../QuotedMessagePreview';
2930

3031
expect.extend(toHaveNoViolations);
3132

@@ -1520,8 +1521,10 @@ describe(`MessageInputFlat only`, () => {
15201521
});
15211522
};
15221523

1523-
const initQuotedMessagePreview = async (message) => {
1524-
await waitFor(() => expect(screen.queryByText(message.text)).not.toBeInTheDocument());
1524+
const initQuotedMessagePreview = async () => {
1525+
await waitFor(() =>
1526+
expect(screen.queryByTestId('quoted-message-preview')).not.toBeInTheDocument(),
1527+
);
15251528

15261529
const quoteButton = await screen.findByText(/^reply$/i);
15271530
await waitFor(() => expect(quoteButton).toBeInTheDocument());
@@ -1550,6 +1553,38 @@ describe(`MessageInputFlat only`, () => {
15501553
await quotedMessagePreviewIsDisplayedCorrectly(mainListMessage);
15511554
});
15521555

1556+
it('renders proper markdown (through default renderText fn)', async () => {
1557+
const m = generateMessage({
1558+
mentioned_users: [{ id: 'john', name: 'John Cena' }],
1559+
text: 'hey @John Cena',
1560+
user,
1561+
});
1562+
await renderComponent({ messageContextOverrides: { message: m } });
1563+
await initQuotedMessagePreview(m);
1564+
1565+
expect(await screen.findByText('@John Cena')).toHaveAttribute('data-user-id');
1566+
});
1567+
1568+
it('uses custom renderText fn if provided', async () => {
1569+
const m = generateMessage({
1570+
text: nanoid(),
1571+
user,
1572+
});
1573+
const fn = jest.fn().mockReturnValue(<div data-testid={m.text}>{m.text}</div>);
1574+
await renderComponent({
1575+
channelProps: {
1576+
QuotedMessagePreview: (props) => (
1577+
<QuotedMessagePreview {...props} renderText={fn} />
1578+
),
1579+
},
1580+
messageContextOverrides: { message: m },
1581+
});
1582+
await initQuotedMessagePreview(m);
1583+
1584+
expect(fn).toHaveBeenCalled();
1585+
expect(await screen.findByTestId(m.text)).toBeInTheDocument();
1586+
});
1587+
15531588
it('is updated on original message update', async () => {
15541589
const { channel, client } = await renderComponent();
15551590
await initQuotedMessagePreview(mainListMessage);

yarn.lock

+4-4
Original file line numberDiff line numberDiff line change
@@ -2359,10 +2359,10 @@
23592359
resolved "https://registry.yarnpkg.com/@stream-io/escape-string-regexp/-/escape-string-regexp-5.0.1.tgz#362505c92799fea6afe4e369993fbbda8690cc37"
23602360
integrity sha512-qIaSrzJXieZqo2fZSYTdzwSbZgHHsT3tkd646vvZhh4fr+9nO4NlvqGmPF43Y+OfZiWf+zYDFgNiPGG5+iZulQ==
23612361

2362-
"@stream-io/stream-chat-css@^5.7.0":
2363-
version "5.7.0"
2364-
resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.7.0.tgz#9626f35ae4eb5320bec90ba27a343c00e5bbd3e7"
2365-
integrity sha512-3CtbS5BV0PfW1kTDJtj1oSoPEreINu2Q9cJEEXUmRguOLb6LMjS3OsSnZq78RYHdECNfta3I2M8JxdFlRTEKSA==
2362+
"@stream-io/stream-chat-css@^5.7.1":
2363+
version "5.7.1"
2364+
resolved "https://registry.yarnpkg.com/@stream-io/stream-chat-css/-/stream-chat-css-5.7.1.tgz#051fb336126e9141bb77bdfd8535326702c6e9ff"
2365+
integrity sha512-iFar7bsI0Rh5aLG3Joeh3kHK6pkulX6alcC9l5D8zN+w7pXOQIQ87jOjIFjqTnxkQp80s2RgehNZt9Vy3zTaIg==
23662366

23672367
"@stream-io/transliterate@^1.5.5":
23682368
version "1.5.5"

0 commit comments

Comments
 (0)