Skip to content

Commit

Permalink
feat: render Markdown within quoted message components (#2640)
Browse files Browse the repository at this point in the history
### 🎯 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`
  • Loading branch information
arnautov-anton authored Feb 19, 2025
1 parent e58bc2a commit 6674cc2
Show file tree
Hide file tree
Showing 7 changed files with 121 additions and 25 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@
"@semantic-release/changelog": "^6.0.2",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@stream-io/stream-chat-css": "^5.7.0",
"@stream-io/stream-chat-css": "^5.7.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
Expand Down
41 changes: 29 additions & 12 deletions src/components/Message/QuotedMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,49 +1,66 @@
import React from 'react';
import React, { useMemo } from 'react';
import clsx from 'clsx';
import type { TranslationLanguages } from 'stream-chat';

import { Attachment as DefaultAttachment } from '../Attachment';
import { Avatar as DefaultAvatar } from '../Avatar';
import { Poll } from '../Poll';

import { useChatContext } from '../../context/ChatContext';
import { useComponentContext } from '../../context/ComponentContext';
import { useMessageContext } from '../../context/MessageContext';
import { useTranslationContext } from '../../context/TranslationContext';
import { useChannelActionContext } from '../../context/ChannelActionContext';
import { renderText as defaultRenderText } from './renderText';
import type { MessageContextValue } from '../../context/MessageContext';

import type { TranslationLanguages } from 'stream-chat';
export type QuotedMessageProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
> = Pick<MessageContextValue<StreamChatGenerics>, 'renderText'>;

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

export const QuotedMessage = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
>() => {
>({
renderText: propsRenderText,
}: QuotedMessageProps) => {
const { Attachment = DefaultAttachment, Avatar: ContextAvatar } =
useComponentContext<StreamChatGenerics>('QuotedMessage');
const { client } = useChatContext();
const { isMyMessage, message } = useMessageContext<StreamChatGenerics>('QuotedMessage');
const {
isMyMessage,
message,
renderText: contextRenderText,
} = useMessageContext<StreamChatGenerics>('QuotedMessage');
const { t, userLanguage } = useTranslationContext('QuotedMessage');
const { jumpToMessage } = useChannelActionContext('QuotedMessage');

const renderText = propsRenderText ?? contextRenderText ?? defaultRenderText;

const Avatar = ContextAvatar || DefaultAvatar;

const { quoted_message } = message;
if (!quoted_message) return null;

const poll = quoted_message.poll_id && client.polls.fromState(quoted_message.poll_id);
const poll = quoted_message?.poll_id && client.polls.fromState(quoted_message.poll_id);
const quotedMessageDeleted =
quoted_message.deleted_at || quoted_message.type === 'deleted';
quoted_message?.deleted_at || quoted_message?.type === 'deleted';

const quotedMessageText = quotedMessageDeleted
? t('This message was deleted...')
: quoted_message.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] ||
quoted_message.text;
: quoted_message?.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] ||
quoted_message?.text;

const quotedMessageAttachment =
quoted_message.attachments?.length && !quotedMessageDeleted
quoted_message?.attachments?.length && !quotedMessageDeleted
? quoted_message.attachments[0]
: null;

const renderedText = useMemo(
() => renderText(quotedMessageText, quoted_message?.mentioned_users),
[quotedMessageText, quoted_message?.mentioned_users, renderText],
);

if (!quoted_message) return null;
if (!quoted_message.poll && !quotedMessageText && !quotedMessageAttachment) return null;

return (
Expand Down Expand Up @@ -80,7 +97,7 @@ export const QuotedMessage = <
className='str-chat__quoted-message-bubble__text'
data-testid='quoted-message-text'
>
{quotedMessageText}
{renderedText}
</div>
</>
)}
Expand Down
42 changes: 38 additions & 4 deletions src/components/Message/__tests__/QuotedMessage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { toHaveNoViolations } from 'jest-axe';
import React from 'react';
import { nanoid } from 'nanoid';
import { axe } from '../../../../axe-helper';

import {
Expand Down Expand Up @@ -106,13 +107,46 @@ describe('QuotedMessage', () => {
expect(results).toHaveNoViolations();
});

it('should rendered text', async () => {
const { container, queryByTestId, queryByText } = await renderQuotedMessage({
it('renders proper markdown (through default renderText fn)', async () => {
const messageText = 'hey @John Cena';
const { container, findByTestId, findByText, queryByTestId } =
await renderQuotedMessage({
customProps: {
message: {
quoted_message: {
mentioned_users: [{ id: 'john', name: 'John Cena' }],
text: messageText,
},
},
},
});

expect(await findByText('@John Cena')).toHaveAttribute('data-user-id');
expect((await findByTestId('quoted-message-text')).textContent).toEqual(messageText);
expect(queryByTestId(quotedAttachmentListTestId)).not.toBeInTheDocument();
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('uses custom renderText fn if provided', async () => {
const messageText = nanoid();
const fn = jest
.fn()
.mockReturnValue(<div data-testid={messageText}>{messageText}</div>);

const { container, findByTestId, queryByTestId } = await renderQuotedMessage({
customProps: {
message: { quoted_message: { text: quotedText } },
message: {
quoted_message: {
text: messageText,
},
},
renderText: fn,
},
});
expect(queryByText(quotedText)).toBeInTheDocument();

expect(fn).toHaveBeenCalled();
expect((await findByTestId('quoted-message-text')).textContent).toEqual(messageText);
expect(queryByTestId(quotedAttachmentListTestId)).not.toBeInTheDocument();
const results = await axe(container);
expect(results).toHaveNoViolations();
Expand Down
3 changes: 2 additions & 1 deletion src/components/MessageInput/MessageInputFlat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ export const MessageInputFlat = <
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
const isRecording = !!recordingController.recordingState;

/* This bit here is needed to make sure that we can get rid of the default behaviour
/**
* This bit here is needed to make sure that we can get rid of the default behaviour
* if need be. Essentially this allows us to pass StopAIGenerationButton={null} and
* completely circumvent the default logic if it's not what we want. We need it as a
* prop because there is no other trivial way to override the SendMessage button otherwise.
Expand Down
11 changes: 10 additions & 1 deletion src/components/MessageInput/QuotedMessagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { useTranslationContext } from '../../context/TranslationContext';

import type { TranslationLanguages } from 'stream-chat';
import type { StreamMessage } from '../../context/ChannelStateContext';
import type { MessageContextValue } from '../../context';
import type { DefaultStreamChatGenerics } from '../../types/types';
import { renderText as defaultRenderText } from '../Message';

export const QuotedMessagePreviewHeader = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
Expand Down Expand Up @@ -41,12 +43,14 @@ export type QuotedMessagePreviewProps<
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
> = {
quotedMessage: StreamMessage<StreamChatGenerics>;
renderText?: MessageContextValue<StreamChatGenerics>['renderText'];
};

export const QuotedMessagePreview = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
>({
quotedMessage,
renderText = defaultRenderText,
}: QuotedMessagePreviewProps<StreamChatGenerics>) => {
const { client } = useChatContext();
const { Attachment = DefaultAttachment, Avatar = DefaultAvatar } =
Expand All @@ -57,6 +61,11 @@ export const QuotedMessagePreview = <
quotedMessage.i18n?.[`${userLanguage}_text` as `${TranslationLanguages}_text`] ||
quotedMessage.text;

const renderedText = useMemo(
() => renderText(quotedMessageText, quotedMessage.mentioned_users),
[quotedMessage.mentioned_users, quotedMessageText, renderText],
);

const quotedMessageAttachment = useMemo(() => {
const [attachment] = quotedMessage.attachments ?? [];
return attachment ? [attachment] : [];
Expand Down Expand Up @@ -91,7 +100,7 @@ export const QuotedMessagePreview = <
className='str-chat__quoted-message-text'
data-testid='quoted-message-text'
>
<p>{quotedMessageText}</p>
{renderedText}
</div>
</>
)}
Expand Down
39 changes: 37 additions & 2 deletions src/components/MessageInput/__tests__/MessageInput.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
initClientWithChannels,
} from '../../../mock-builders';
import { generatePoll } from '../../../mock-builders/generator/poll';
import { QuotedMessagePreview } from '../QuotedMessagePreview';

expect.extend(toHaveNoViolations);

Expand Down Expand Up @@ -1520,8 +1521,10 @@ describe(`MessageInputFlat only`, () => {
});
};

const initQuotedMessagePreview = async (message) => {
await waitFor(() => expect(screen.queryByText(message.text)).not.toBeInTheDocument());
const initQuotedMessagePreview = async () => {
await waitFor(() =>
expect(screen.queryByTestId('quoted-message-preview')).not.toBeInTheDocument(),
);

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

it('renders proper markdown (through default renderText fn)', async () => {
const m = generateMessage({
mentioned_users: [{ id: 'john', name: 'John Cena' }],
text: 'hey @John Cena',
user,
});
await renderComponent({ messageContextOverrides: { message: m } });
await initQuotedMessagePreview(m);

expect(await screen.findByText('@John Cena')).toHaveAttribute('data-user-id');
});

it('uses custom renderText fn if provided', async () => {
const m = generateMessage({
text: nanoid(),
user,
});
const fn = jest.fn().mockReturnValue(<div data-testid={m.text}>{m.text}</div>);
await renderComponent({
channelProps: {
QuotedMessagePreview: (props) => (
<QuotedMessagePreview {...props} renderText={fn} />
),
},
messageContextOverrides: { message: m },
});
await initQuotedMessagePreview(m);

expect(fn).toHaveBeenCalled();
expect(await screen.findByTestId(m.text)).toBeInTheDocument();
});

it('is updated on original message update', async () => {
const { channel, client } = await renderComponent();
await initQuotedMessagePreview(mainListMessage);
Expand Down
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2359,10 +2359,10 @@
resolved "https://registry.yarnpkg.com/@stream-io/escape-string-regexp/-/escape-string-regexp-5.0.1.tgz#362505c92799fea6afe4e369993fbbda8690cc37"
integrity sha512-qIaSrzJXieZqo2fZSYTdzwSbZgHHsT3tkd646vvZhh4fr+9nO4NlvqGmPF43Y+OfZiWf+zYDFgNiPGG5+iZulQ==

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

"@stream-io/transliterate@^1.5.5":
version "1.5.5"
Expand Down

0 comments on commit 6674cc2

Please sign in to comment.