diff --git a/docusaurus/docs/React/components/core-components/message-list.mdx b/docusaurus/docs/React/components/core-components/message-list.mdx index b976a6963..bd6bf611f 100644 --- a/docusaurus/docs/React/components/core-components/message-list.mdx +++ b/docusaurus/docs/React/components/core-components/message-list.mdx @@ -122,42 +122,6 @@ const App = () => ( ); ``` -#### Custom element rendering - -If you feel like the default output is sufficient, but you'd like to adjust how certain [ReactMarkdown components](https://github.com/remarkjs/react-markdown#appendix-b-components) look like (like `strong` element generated by typing \*\*strong\*\*) you can do so by passing down options to a third argument of the default `renderText` function: - -:::note -Types `mention` and `emoji` are special case component types generated by our SDK's custom rehype plugins. -::: - -```tsx -import { renderText } from 'stream-chat-react'; - -const StrongComponent = ({ children }) => {children}; - -const MentionComponent = ({ children, node: { mentionedUser } }) => ( - - {children} - -); - -const App = () => ( - - - - - renderText(text, mentionedUsers, { - customMarkDownRenderers: { strong: StrongComponent, mention: MentionComponent }, - }) - } - /> - - - -); -``` - #### Custom remark and rehype plugins If you would like to extend the array of plugins used to parse the markdown, you can provide your own lists of remark resp. rehype plugins. The logic that determines what plugins are used and in which order can be specified in custom `getRehypePlugins` and `getRemarkPlugins` functions. These receive the default array of rehype and remark plugins for further customization. Both custom functions ought to be passed to the third `renderText()` parameter. An example follows: @@ -218,6 +182,79 @@ const CustomMessageList = () => ( ); ``` +#### Optional remark and rehype plugins + +The SDK provides the following plugins that are not applied to the text parsing by the default `renderText()` implementation. However, these can be included by simply overriding the defaults with `getRemarkPlugins` and `getRehypePlugins` parameters as described in [the section about custom plugins](#custom-remark-and-rehype-plugins). + +Currently, there are the following optional remark plugins available in the SDK: + +- `htmlToTextPlugin` - keeps the HTML tags in the resulting text string. +- `keepLineBreaksPlugin` - replaces empty lines in text with line breaks ([according to the CommonMark Markdown specification](https://spec.commonmark.org/0.30/#hard-line-breaks), the empty lines - meaning newline characters `\n` - are not transformed to `
` elements). + +These can be plugged in as follows: + +```tsx +import { UserResponse } from 'stream-chat'; +import { + htmlToTextPlugin, + keepLineBreaksPlugin, + MessageList, + MessageListProps, + renderText, + RenderTextPluginConfigurator, +} from 'stream-chat-react'; + +const getRemarkPlugins: RenderTextPluginConfigurator = (plugins) => [ + htmlToTextPlugin, + keepLineBreaksPlugin, + ...plugins, +]; + +function customRenderText(text?: string, mentionedUsers?: UserResponse[]) { + return renderText(text, mentionedUsers, { getRemarkPlugins }); +} + +export const CustomMessageList = (props: MessageListProps) => ( + +); +``` + +#### Custom element rendering + +If you feel like the default output is sufficient, but you'd like to adjust how certain [ReactMarkdown components](https://github.com/remarkjs/react-markdown#appendix-b-components) look like (like `strong` element generated by typing \*\*strong\*\*) you can do so by passing down options to a third argument of the default `renderText` function: + +:::note +Types `mention` and `emoji` are special case component types generated by our SDK's custom rehype plugins. +::: + +```tsx +import { renderText } from 'stream-chat-react'; + +const StrongComponent = ({ children }) => {children}; + +const MentionComponent = ({ children, node: { mentionedUser } }) => ( + + {children} + +); + +const App = () => ( + + + + + renderText(text, mentionedUsers, { + customMarkDownRenderers: { strong: StrongComponent, mention: MentionComponent }, + }) + } + /> + + + +); +``` + ## Props ### additionalMessageInputProps diff --git a/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.js.snap b/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.js.snap index 0394d97aa..b366282ae 100644 --- a/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.js.snap +++ b/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.js.snap @@ -1,5 +1,653 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`htmlToTextPlugin absent does not keep HTML in text 1`] = `null`; + +exports[`htmlToTextPlugin present keeps HTML in text 1`] = ` +"
+ +
" +`; + +exports[`keepLineBreaksPlugin absent does not keep line breaks around a blockquote 1`] = ` +Array [ +

+ a +

, + " +", +
+ + +

+ b +

+ + +
, + " +", +

+ c +

, +] +`; + +exports[`keepLineBreaksPlugin absent does not keep line breaks around a code block 1`] = ` +Array [ +

+ a +

, + " +", +

+ + b + +

, + " +", +

+ c +

, +] +`; + +exports[`keepLineBreaksPlugin absent does not keep line breaks around a horizontal rule 1`] = ` +Array [ +

+ a +

, + " +", + " +", +

+ b +

, +] +`; + +exports[`keepLineBreaksPlugin absent does not keep line breaks around a strikethrough 1`] = ` +Array [ +

+ a +

, + " +", +

+ ~~xxx~~ +

, + " +", +

+ b +

, +] +`; + +exports[`keepLineBreaksPlugin absent does not keep line breaks around a table 1`] = ` +Array [ +

+ a +

, + " +", +

+ | a | b | c | d | +| - | :- | -: | :-: | +| a | b | c | d | +

, + " +", +

+ c +

, +] +`; + +exports[`keepLineBreaksPlugin absent does not keep line breaks between paragraphs 1`] = ` +Array [ +

+ a +

, + " +", +

+ b +

, + " +", +

+ c +

, +] +`; + +exports[`keepLineBreaksPlugin absent does not keep line breaks between the items in an ordered list 1`] = ` +
    + + +
  1. + + +

    + item 1 +

    + + +
  2. + + +
  3. + + +

    + item 2 +

    + + +
  4. + + +
  5. + + +

    + item 3 +

    + + +
  6. + + +
+`; + +exports[`keepLineBreaksPlugin absent does not keep line breaks between the items in an unordered list 1`] = ` + +`; + +exports[`keepLineBreaksPlugin absent does not keep line breaks under a heading 1`] = ` +Array [ + "Heading", + " +", +

+ a +

, +] +`; + +exports[`keepLineBreaksPlugin present keeps line breaks around a blockquote 1`] = ` +Array [ +

+ a +

, + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +
+

+ b +

+ + +
, + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +

+ c +

, +] +`; + +exports[`keepLineBreaksPlugin present keeps line breaks around a code block 1`] = ` +Array [ +

+ a +

, + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +

+ + b + +

, + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +

+ c +

, +] +`; + +exports[`keepLineBreaksPlugin present keeps line breaks around a horizontal rule 1`] = ` +Array [ +

+ a +

, + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +

+ b +

, +] +`; + +exports[`keepLineBreaksPlugin present keeps line breaks around a strikethrough 1`] = ` +Array [ +

+ a +

, + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +

+ ~~xxx~~ +

, + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +

+ b +

, +] +`; + +exports[`keepLineBreaksPlugin present keeps line breaks around a table 1`] = ` +Array [ +

+ a +

, + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +

+ | a | b | c | d | +| - | :- | -: | :-: | +| a | b | c | d | +

, + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +

+ c +

, +] +`; + +exports[`keepLineBreaksPlugin present keeps line breaks between paragraphs 1`] = ` +Array [ +

+ a +

, + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +

+ b +

, + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +

+ c +

, +] +`; + +exports[`keepLineBreaksPlugin present keeps line breaks between the items in an ordered list 1`] = ` +
    + + +
  1. + + +

    + item 1 +

    + + +
  2. + + +
    + + + + + + +
    + + + + + + +
  3. +

    + item 2 +

    + + +
  4. + + +
    + + + + + + +
    + + + + + + +
  5. +

    + item 3 +

    + + +
  6. + + +
+`; + +exports[`keepLineBreaksPlugin present keeps line breaks between the items in an unordered list 1`] = ` + +`; + +exports[`keepLineBreaksPlugin present keeps line breaks under a heading 1`] = ` +Array [ + "Heading", + " +", +
, + " +", + " +", + " +", +
, + " +", + " +", + " +", +

+ a +

, +] +`; + exports[`renderText handles the special case where there are pronouns in the name 1`] = `

hey, diff --git a/src/components/Message/renderText/__tests__/renderText.test.js b/src/components/Message/renderText/__tests__/renderText.test.js index f0d5631da..195487a09 100644 --- a/src/components/Message/renderText/__tests__/renderText.test.js +++ b/src/components/Message/renderText/__tests__/renderText.test.js @@ -1,8 +1,11 @@ import React from 'react'; import renderer from 'react-test-renderer'; -import { defaultAllowedTagNames, renderText } from '../renderText'; import { findAndReplace } from 'hast-util-find-and-replace'; import { u } from 'unist-builder'; +import { htmlToTextPlugin, keepLineBreaksPlugin } from '../remarkPlugins'; +import { defaultAllowedTagNames, renderText } from '../renderText'; + +const strikeThroughText = '~~xxx~~'; describe(`renderText`, () => { it('handles the special case where user name matches to an e-mail pattern - 1', () => { @@ -201,7 +204,6 @@ describe(`renderText`, () => { `); }); - const strikeThroughText = '~~xxx~~'; it('renders strikethrough', () => { const Markdown = renderText(strikeThroughText); const tree = renderer.create(Markdown).toJSON(); @@ -270,3 +272,131 @@ describe(`renderText`, () => { `); }); }); + +describe('keepLineBreaksPlugin', () => { + const lineBreaks = '\n\n\n'; + const paragraphText = `a${lineBreaks}b${lineBreaks}c`; + const unorderedListText = `* item 1${lineBreaks}* item 2${lineBreaks}* item 3`; + const orderedListText = `1. item 1${lineBreaks}2. item 2${lineBreaks}3. item 3`; + const headingText = `## Heading${lineBreaks}a`; + const codeBlockText = 'a\n\n\n```b```\n\n\nc'; + const horizontalRuleText = `a${lineBreaks}---${lineBreaks}b`; + const blockquoteText = `a${lineBreaks}>b${lineBreaks}c`; + const withStrikeThroughText = `a${lineBreaks}${strikeThroughText}${lineBreaks}b`; + const tableText = `a${lineBreaks}| a | b | c | d |\n| - | :- | -: | :-: |\n| a | b | c | d |${lineBreaks}c`; + + const doRenderText = (text, present) => { + const Markdown = renderText( + text, + {}, + { getRemarkPlugins: () => (present ? [keepLineBreaksPlugin] : []) }, + ); + return renderer.create(Markdown).toJSON(); + }; + + describe('absent', () => { + const present = false; + it(`does not keep line breaks between paragraphs`, () => { + const tree = doRenderText(paragraphText, present); + expect(tree).toMatchSnapshot(); + }); + it(`does not keep line breaks between the items in an unordered list`, () => { + const tree = doRenderText(unorderedListText, present); + expect(tree).toMatchSnapshot(); + }); + it(`does not keep line breaks between the items in an ordered list`, () => { + const tree = doRenderText(orderedListText, present); + expect(tree).toMatchSnapshot(); + }); + it(`does not keep line breaks under a heading`, () => { + const tree = doRenderText(headingText, present); + expect(tree).toMatchSnapshot(); + }); + it(`does not keep line breaks around a horizontal rule`, () => { + const tree = doRenderText(horizontalRuleText, present); + expect(tree).toMatchSnapshot(); + }); + it(`does not keep line breaks around a code block`, () => { + const tree = doRenderText(codeBlockText, present); + expect(tree).toMatchSnapshot(); + }); + it(`does not keep line breaks around a blockquote`, () => { + const tree = doRenderText(blockquoteText, present); + expect(tree).toMatchSnapshot(); + }); + it(`does not keep line breaks around a strikethrough`, () => { + const tree = doRenderText(withStrikeThroughText, present); + expect(tree).toMatchSnapshot(); + }); + it(`does not keep line breaks around a table`, () => { + const tree = doRenderText(tableText, present); + expect(tree).toMatchSnapshot(); + }); + }); + describe('present', () => { + const present = true; + it(`keeps line breaks between paragraphs`, () => { + const tree = doRenderText(paragraphText, present); + expect(tree).toMatchSnapshot(); + }); + it(`keeps line breaks between the items in an unordered list`, () => { + const tree = doRenderText(unorderedListText, present); + expect(tree).toMatchSnapshot(); + }); + it(`keeps line breaks between the items in an ordered list`, () => { + const tree = doRenderText(orderedListText, present); + expect(tree).toMatchSnapshot(); + }); + it(`keeps line breaks under a heading`, () => { + const tree = doRenderText(headingText, present); + expect(tree).toMatchSnapshot(); + }); + it(`keeps line breaks around a horizontal rule`, () => { + const tree = doRenderText(horizontalRuleText, present); + expect(tree).toMatchSnapshot(); + }); + it(`keeps line breaks around a code block`, () => { + const tree = doRenderText(codeBlockText, present); + expect(tree).toMatchSnapshot(); + }); + it(`keeps line breaks around a blockquote`, () => { + const tree = doRenderText(blockquoteText, present); + expect(tree).toMatchSnapshot(); + }); + + it(`keeps line breaks around a strikethrough`, () => { + const tree = doRenderText(withStrikeThroughText, present); + expect(tree).toMatchSnapshot(); + }); + it(`keeps line breaks around a table`, () => { + const tree = doRenderText(tableText, present); + expect(tree).toMatchSnapshot(); + }); + }); +}); + +describe('htmlToTextPlugin', () => { + const renderTextWithHtml = (withPlugin) => { + const textWithHtml = ` +

+ +
+`; + const Markdown = renderText( + textWithHtml, + {}, + { getRemarkPlugins: () => (withPlugin ? [htmlToTextPlugin] : []) }, + ); + return renderer.create(Markdown).toJSON(); + }; + + it(`absent does not keep HTML in text`, () => { + const tree = renderTextWithHtml(false); + expect(tree).toMatchSnapshot(); + }); + + it(`present keeps HTML in text`, () => { + const tree = renderTextWithHtml(true); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/Message/renderText/index.ts b/src/components/Message/renderText/index.ts index c655f8e14..f49f23ad6 100644 --- a/src/components/Message/renderText/index.ts +++ b/src/components/Message/renderText/index.ts @@ -1,4 +1,6 @@ export { MentionProps } from './Mention'; -export { emojiMarkdownPlugin, mentionsMarkdownPlugin } from './rehypePlugins'; export { escapeRegExp, matchMarkdownLinks, messageCodeBlocks } from './regex'; +export * from './rehypePlugins'; +export * from './remarkPlugins'; export * from './renderText'; +export { HNode } from './types'; diff --git a/src/components/Message/renderText/rehypePlugins/emojiMarkdownPlugin.ts b/src/components/Message/renderText/rehypePlugins/emojiMarkdownPlugin.ts new file mode 100644 index 000000000..e03a8ff4e --- /dev/null +++ b/src/components/Message/renderText/rehypePlugins/emojiMarkdownPlugin.ts @@ -0,0 +1,14 @@ +import { findAndReplace, ReplaceFunction } from 'hast-util-find-and-replace'; +import { u } from 'unist-builder'; +import emojiRegex from 'emoji-regex'; + +import type { HNode } from '../types'; + +export const emojiMarkdownPlugin = () => { + const replace: ReplaceFunction = (match) => + u('element', { tagName: 'emoji' }, [u('text', match)]); + + const transform = (node: HNode) => findAndReplace(node, emojiRegex(), replace); + + return transform; +}; diff --git a/src/components/Message/renderText/rehypePlugins/index.ts b/src/components/Message/renderText/rehypePlugins/index.ts new file mode 100644 index 000000000..f118ee95f --- /dev/null +++ b/src/components/Message/renderText/rehypePlugins/index.ts @@ -0,0 +1,2 @@ +export * from './emojiMarkdownPlugin'; +export * from './mentionsMarkdownPlugin'; diff --git a/src/components/Message/renderText/rehypePlugins.ts b/src/components/Message/renderText/rehypePlugins/mentionsMarkdownPlugin.ts similarity index 82% rename from src/components/Message/renderText/rehypePlugins.ts rename to src/components/Message/renderText/rehypePlugins/mentionsMarkdownPlugin.ts index 0732da73e..b72d06dc4 100644 --- a/src/components/Message/renderText/rehypePlugins.ts +++ b/src/components/Message/renderText/rehypePlugins/mentionsMarkdownPlugin.ts @@ -1,16 +1,13 @@ +import { DefaultStreamChatGenerics } from '../../../../types/types'; +import { UserResponse } from 'stream-chat'; +import { escapeRegExp } from '../regex'; import { findAndReplace, ReplaceFunction } from 'hast-util-find-and-replace'; import { u } from 'unist-builder'; import { visit } from 'unist-util-visit'; -import emojiRegex from 'emoji-regex'; -import { escapeRegExp } from './regex'; - -import type { Content, Root } from 'hast'; import type { Element } from 'react-markdown/lib/ast-to-react'; -import type { UserResponse } from 'stream-chat'; -import type { DefaultStreamChatGenerics } from '../../../types/types'; +import type { HNode } from '../types'; -export type HNode = Content | Root; export const mentionsMarkdownPlugin = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics >( @@ -71,12 +68,3 @@ export const mentionsMarkdownPlugin = < return transform; }; - -export const emojiMarkdownPlugin = () => { - const replace: ReplaceFunction = (match) => - u('element', { tagName: 'emoji' }, [u('text', match)]); - - const transform = (node: HNode) => findAndReplace(node, emojiRegex(), replace); - - return transform; -}; diff --git a/src/components/Message/renderText/remarkPlugins/htmlToTextPlugin.ts b/src/components/Message/renderText/remarkPlugins/htmlToTextPlugin.ts new file mode 100644 index 000000000..2708d0b6b --- /dev/null +++ b/src/components/Message/renderText/remarkPlugins/htmlToTextPlugin.ts @@ -0,0 +1,14 @@ +import { visit, Visitor } from 'unist-util-visit'; + +import type { HNode } from '../types'; + +const visitor: Visitor = (node) => { + if (node.type !== 'html') return; + + node.type = 'text'; +}; +const transform = (tree: HNode) => { + visit(tree, visitor); +}; + +export const htmlToTextPlugin = () => transform; diff --git a/src/components/Message/renderText/remarkPlugins/index.ts b/src/components/Message/renderText/remarkPlugins/index.ts new file mode 100644 index 000000000..525dc9f2c --- /dev/null +++ b/src/components/Message/renderText/remarkPlugins/index.ts @@ -0,0 +1,2 @@ +export * from './htmlToTextPlugin'; +export * from './keepLineBreaksPlugin'; diff --git a/src/components/Message/renderText/remarkPlugins/keepLineBreaksPlugin.ts b/src/components/Message/renderText/remarkPlugins/keepLineBreaksPlugin.ts new file mode 100644 index 000000000..2371974ce --- /dev/null +++ b/src/components/Message/renderText/remarkPlugins/keepLineBreaksPlugin.ts @@ -0,0 +1,40 @@ +import { visit, Visitor } from 'unist-util-visit'; +import { u } from 'unist-builder'; + +import type { Break } from 'mdast'; +import type { HNode } from '../types'; + +const visitor: Visitor = (node, index, parent) => { + if (index === null || index === 0) return; + if (!parent) return; + if (!node.position) return; + + const prevSibling = parent.children.at(index - 1); + if (!prevSibling?.position) return; + + if (node.position.start.line === prevSibling.position.start.line) return false; + const ownStartLine = node.position.start.line; + const prevEndLine = prevSibling.position.end.line; + + // the -1 is adjustment for the single line break into which multiple line breaks are converted + const countTruncatedLineBreaks = ownStartLine - prevEndLine - 1; + if (countTruncatedLineBreaks < 1) return; + + const lineBreaks = Array.from({ length: countTruncatedLineBreaks }, () => + u('break', { tagName: 'br' }), + ); + + // @ts-ignore + parent.children = [ + ...parent.children.slice(0, index), + ...lineBreaks, + ...parent.children.slice(index), + ]; + + return; +}; +const transform = (tree: HNode) => { + visit(tree, visitor); +}; + +export const keepLineBreaksPlugin = () => transform; diff --git a/src/components/Message/renderText/types.ts b/src/components/Message/renderText/types.ts new file mode 100644 index 000000000..b5cc8b50f --- /dev/null +++ b/src/components/Message/renderText/types.ts @@ -0,0 +1,3 @@ +import { Content, Root } from 'hast'; + +export type HNode = Content | Root;