Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: render Markdown within quoted message components #2640

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
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
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