diff --git a/docusaurus/docs/React/components/contexts/message-context.mdx b/docusaurus/docs/React/components/contexts/message-context.mdx index e12c8226f..942929deb 100644 --- a/docusaurus/docs/React/components/contexts/message-context.mdx +++ b/docusaurus/docs/React/components/contexts/message-context.mdx @@ -341,7 +341,7 @@ Custom function to render message text content. | Type | Default | | -------- | -------------------------------------------------------------------------------------- | -| function | [renderText](https://github.com/GetStream/stream-chat-react/blob/master/src/utils.tsx) | +| function | | ### setEditingState diff --git a/docusaurus/docs/React/components/core-components/message-list.mdx b/docusaurus/docs/React/components/core-components/message-list.mdx index 3e64e4c7f..3f4bee91a 100644 --- a/docusaurus/docs/React/components/core-components/message-list.mdx +++ b/docusaurus/docs/React/components/core-components/message-list.mdx @@ -66,10 +66,20 @@ The `MessageList` internally creates a mapping of message id to a style group. T ### Default behaviour -The output of the default [`renderText`](#render-text) function is a message text processed by the `ReactMarkdown` component with [`remark`](https://github.com/remarkjs/remark/blob/main/doc/plugins.md) [`remark-gfm`](https://github.com/remarkjs/remark-gfm) plugin and custom [`rehype`](https://github.com/rehypejs/rehype/blob/main/doc/plugins.md) plugins for mentions and emojis. +The default [`renderText`](#render-text) function parses a markdown string and outputs a `ReactElement`. Under the hood, the output is generated by the `ReactMarkdown` component from [react-markdown library](https://github.com/remarkjs/react-markdown). The component transforms the markdown to `ReactElement` by using [`remark` parser](https://github.com/remarkjs/remark/tree/main) and [`remark`](https://github.com/remarkjs/remark/blob/main/doc/plugins.md) and [`rehype`](https://github.com/rehypejs/rehype/blob/main/doc/plugins.md) plugins. + +The default `remark` plugins used by SDK are: + +1. [`remark-gfm`](https://github.com/remarkjs/remark-gfm) - a third party plugin to add GitHub-like markdown support + +The default `rehype` plugins (both specific to this SDK) are: +1. plugin to render user mentions +2. plugin to render emojis ### Overriding defaults +#### Custom `renderText` function + If you don't want your chat implementation to support markdown syntax by default you can override the default behaviour by creating a custom `renderText` function which returns a React node and passing it down to the `MessageList` or `MessageSimple` component via `renderText` property. For this particular example we'll create a very primitive one which takes the message text passed down to it as a first argument and returns it wrapped in `span` element: @@ -112,10 +122,12 @@ const App = () => ( ); ``` -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: +#### 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 custom rehype plugins. Currently we do not allow to add custom rehype/remark plugins to our default `renderText` function due to compatibility reasons regarding our custom plugins. +Types `mention` and `emoji` are special case component types generated by our SDK's custom rehype plugins. ::: ```tsx @@ -146,6 +158,38 @@ const App = () => ( ); ``` +#### 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: + +:::note +It is important to understand what constitutes a rehype or remark plugin. A good start is to learn about the library called [`react-remark`](https://github.com/remarkjs/react-remark) which is used under the hood in our `renderText()` function. +::: + + +```tsx +import { renderText, RenderTextPluginConfigurator } from 'stream-chat-react'; +import {customRehypePlugin} from './rehypePlugins'; +import {customRemarkPlugin} from './remarkPlugins'; + +const getRehypePlugins: RenderTextPluginConfigurator = (plugins) => { + return [customRehypePlugin, ...plugins]; +} +const getRemarkPlugins: RenderTextPluginConfigurator = (plugins) => { + return [customRemarkPlugin, ...plugins]; +} + +const customRenderText = (text, mentionedUsers) => + renderText(text, mentionedUsers, { + getRehypePlugins, + getRemarkPlugins + }); + +const CustomMessageList = () => ( + +); +``` + ## Props ### additionalMessageInputProps @@ -434,7 +478,7 @@ Custom function to render message text content. | Type | Default | | -------- | -------------------------------------------------------------------------------------- | -| function | [renderText](https://github.com/GetStream/stream-chat-react/blob/master/src/utils.tsx) | +| function | | ### retrySendMessage diff --git a/docusaurus/docs/React/components/message-components/message-ui.mdx b/docusaurus/docs/React/components/message-components/message-ui.mdx index a4c7cd51d..e464923ab 100644 --- a/docusaurus/docs/React/components/message-components/message-ui.mdx +++ b/docusaurus/docs/React/components/message-components/message-ui.mdx @@ -460,7 +460,7 @@ Custom function to render message text content (overrides the function stored in | Type | Default | | -------- | -------------------------------------------------------------------------------------- | -| function | [renderText](https://github.com/GetStream/stream-chat-react/blob/master/src/utils.tsx) | +| function | | ### setEditingState diff --git a/docusaurus/docs/React/components/message-components/message.mdx b/docusaurus/docs/React/components/message-components/message.mdx index 8a0c75b9c..6889cf391 100644 --- a/docusaurus/docs/React/components/message-components/message.mdx +++ b/docusaurus/docs/React/components/message-components/message.mdx @@ -326,7 +326,7 @@ Custom function to render message text content. | Type | Default | | -------- | -------------------------------------------------------------------------------------- | -| function | [renderText](https://github.com/GetStream/stream-chat-react/blob/master/src/utils.tsx) | +| function | | ### retrySendMessage diff --git a/src/__tests__/utils.test.js b/src/__tests__/utils.test.js deleted file mode 100644 index 8099a50e4..000000000 --- a/src/__tests__/utils.test.js +++ /dev/null @@ -1,115 +0,0 @@ -import React from 'react'; -import renderer from 'react-test-renderer'; -import { renderText } from '../utils'; - -describe(`renderText`, () => { - it('handles the special case where user name matches to an e-mail pattern - 1', () => { - const Markdown = renderText( - 'Hello @username@email.com, is username@email.com your @primary e-mail?', - [{ id: 'id-username@email.com', name: 'username@email.com' }], - ); - const tree = renderer.create(Markdown).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('handles the special case where user name matches to an e-mail pattern - 2', () => { - const Markdown = renderText( - 'username@email.com @username@email.com is this the right address?', - [{ id: 'id-username@email.com', name: 'username@email.com' }], - ); - const tree = renderer.create(Markdown).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('handles the special case where user name matches to an e-mail pattern - 3', () => { - const Markdown = renderText( - '@username@email.com @username@email.com @username@email.com @username@email.com', - [{ id: 'id-username@email.com', name: 'username@email.com' }], - ); - const tree = renderer.create(Markdown).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('handles the special case where user name matches to an e-mail pattern - 4', () => { - const Markdown = renderText( - '@username@email.com @username@email.com username@email.com @username@email.com', - [{ id: 'id-username@email.com', name: 'username@email.com' }], - ); - const tree = renderer.create(Markdown).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('renders custom mention', () => { - const Markdown = renderText( - '@username@email.com @username@email.com username@email.com @username@email.com', - [{ id: 'id-username@email.com', name: 'username@email.com' }], - { - customMarkDownRenderers: { - mention: function MyMention(props) { - return ( - - {props.children} - - ); - }, - }, - }, - ); - const tree = renderer.create(Markdown).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('renders standard markdown text', () => { - const Markdown = renderText('Hi, shall we meet on **Tuesday**?', []); - const tree = renderer.create(Markdown).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('renders remark-gfm list and strikethrough correctly', () => { - const Markdown = renderText( - 'Pick a time to meet:\n- Wednesday\n- Thursday\n- ~~Sunday~~\n- ~Monday~\n', - [], - ); - const tree = renderer.create(Markdown).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it("handles the special case where there's at least one mention and @ symbol at the end", () => { - const Markdown = renderText('@username@email.com @', [ - { id: 'id-username@email.com', name: 'username@email.com' }, - ]); - const tree = renderer.create(Markdown).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('handles the special case where there are pronouns in the name', () => { - const Markdown = renderText('hey, @John (they/them), how are you?', [ - { id: 'john', name: 'John (they/them)' }, - ]); - const tree = renderer.create(Markdown).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('handles the special case where there is a forward slash in the name', () => { - const Markdown = renderText('hey, @John/Cena, how are you?', [ - { id: 'john', name: 'John/Cena' }, - ]); - const tree = renderer.create(Markdown).toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('handles the special case where there is a backslash in the name', () => { - const Markdown = renderText('hey, @John\\Cena, how are you?', [ - { id: 'john', name: 'John\\Cena' }, - ]); - const tree = renderer.create(Markdown).toJSON(); - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/src/components/AutoCompleteTextarea/List.jsx b/src/components/AutoCompleteTextarea/List.jsx index 9775483ff..361c12019 100644 --- a/src/components/AutoCompleteTextarea/List.jsx +++ b/src/components/AutoCompleteTextarea/List.jsx @@ -3,10 +3,10 @@ import clsx from 'clsx'; import { useComponentContext } from '../../context/ComponentContext'; import { useChatContext } from '../../context/ChatContext'; -import { escapeRegExp } from '../../utils'; import { Item } from './Item'; import { DefaultSuggestionListHeader } from './Header'; +import { escapeRegExp } from '../Message/renderText'; export const List = ({ className, diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index 85f717aee..5579f2b0f 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; - -import { getWholeChar } from '../../utils'; +import { getWholeChar } from '../../utils/getWholeChar'; import type { UserResponse } from 'stream-chat'; diff --git a/src/components/Message/FixedHeightMessage.tsx b/src/components/Message/FixedHeightMessage.tsx index 9f80fd854..2bae88e67 100644 --- a/src/components/Message/FixedHeightMessage.tsx +++ b/src/components/Message/FixedHeightMessage.tsx @@ -14,7 +14,7 @@ import { useChatContext } from '../../context/ChatContext'; import { useComponentContext } from '../../context/ComponentContext'; import { useMessageContext } from '../../context/MessageContext'; import { useTranslationContext } from '../../context/TranslationContext'; -import { renderText } from '../../utils'; +import { renderText } from './renderText'; import type { TranslationLanguages } from 'stream-chat'; diff --git a/src/components/Message/MessageText.tsx b/src/components/Message/MessageText.tsx index 3be04dfd5..4f806d836 100644 --- a/src/components/Message/MessageText.tsx +++ b/src/components/Message/MessageText.tsx @@ -1,10 +1,10 @@ import React, { useMemo } from 'react'; import { QuotedMessage as DefaultQuotedMessage } from './QuotedMessage'; -import { messageHasAttachments } from './utils'; +import { isOnlyEmojis, messageHasAttachments } from './utils'; import { useComponentContext, useMessageContext, useTranslationContext } from '../../context'; -import { renderText as defaultRenderText, isOnlyEmojis } from '../../utils'; +import { renderText as defaultRenderText } from './renderText'; import type { TranslationLanguages } from 'stream-chat'; import type { MessageContextValue, StreamMessage } from '../../context'; diff --git a/src/components/Message/index.ts b/src/components/Message/index.ts index e9d61503a..fd48f87c5 100644 --- a/src/components/Message/index.ts +++ b/src/components/Message/index.ts @@ -10,5 +10,6 @@ export * from './MessageStatus'; export * from './MessageText'; export * from './MessageTimestamp'; export * from './QuotedMessage'; +export * from './renderText'; export * from './types'; export * from './utils'; diff --git a/src/components/Message/renderText/Anchor.tsx b/src/components/Message/renderText/Anchor.tsx new file mode 100644 index 000000000..f0969057d --- /dev/null +++ b/src/components/Message/renderText/Anchor.tsx @@ -0,0 +1,21 @@ +import clsx from 'clsx'; +import React, { ComponentProps } from 'react'; +import { ReactMarkdownProps } from 'react-markdown/lib/complex-types'; + +export const Anchor = ({ children, href }: ComponentProps<'a'> & ReactMarkdownProps) => { + const isEmail = href?.startsWith('mailto:'); + const isUrl = href?.startsWith('http'); + + if (!href || (!isEmail && !isUrl)) return <>{children}; + + return ( + + {children} + + ); +}; diff --git a/src/components/Message/renderText/Emoji.tsx b/src/components/Message/renderText/Emoji.tsx new file mode 100644 index 000000000..0a7388b44 --- /dev/null +++ b/src/components/Message/renderText/Emoji.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { ReactMarkdownProps } from 'react-markdown/lib/complex-types'; + +export const Emoji = ({ children }: ReactMarkdownProps) => ( + + {children} + +); diff --git a/src/components/Message/renderText/Mention.tsx b/src/components/Message/renderText/Mention.tsx new file mode 100644 index 000000000..5aa2b6851 --- /dev/null +++ b/src/components/Message/renderText/Mention.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import type { ReactMarkdownProps } from 'react-markdown/lib/complex-types'; +import type { UserResponse } from 'stream-chat'; +import type { DefaultStreamChatGenerics } from '../../../types/types'; + +export type MentionProps< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = ReactMarkdownProps & { + /** + * @deprecated will be removed in the next major release, transition to using `node.mentionedUser` instead + */ + mentioned_user: UserResponse; + node: { + /** + * @deprecated will be removed in the next major release, transition to using `node.mentionedUser` instead + */ + mentioned_user: UserResponse; + mentionedUser: UserResponse; + }; +}; +export const Mention = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>({ + children, + node: { mentionedUser }, +}: MentionProps) => ( + + {children} + +); diff --git a/src/__tests__/__snapshots__/utils.test.js.snap b/src/components/Message/renderText/__tests__/__snapshots__/renderText.test.js.snap similarity index 100% rename from src/__tests__/__snapshots__/utils.test.js.snap rename to src/components/Message/renderText/__tests__/__snapshots__/renderText.test.js.snap diff --git a/src/components/Message/renderText/__tests__/renderText.test.js b/src/components/Message/renderText/__tests__/renderText.test.js new file mode 100644 index 000000000..f0d5631da --- /dev/null +++ b/src/components/Message/renderText/__tests__/renderText.test.js @@ -0,0 +1,272 @@ +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'; + +describe(`renderText`, () => { + it('handles the special case where user name matches to an e-mail pattern - 1', () => { + const Markdown = renderText( + 'Hello @username@email.com, is username@email.com your @primary e-mail?', + [{ id: 'id-username@email.com', name: 'username@email.com' }], + ); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('handles the special case where user name matches to an e-mail pattern - 2', () => { + const Markdown = renderText( + 'username@email.com @username@email.com is this the right address?', + [{ id: 'id-username@email.com', name: 'username@email.com' }], + ); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('handles the special case where user name matches to an e-mail pattern - 3', () => { + const Markdown = renderText( + '@username@email.com @username@email.com @username@email.com @username@email.com', + [{ id: 'id-username@email.com', name: 'username@email.com' }], + ); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('handles the special case where user name matches to an e-mail pattern - 4', () => { + const Markdown = renderText( + '@username@email.com @username@email.com username@email.com @username@email.com', + [{ id: 'id-username@email.com', name: 'username@email.com' }], + ); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders custom mention', () => { + const Markdown = renderText( + '@username@email.com @username@email.com username@email.com @username@email.com', + [{ id: 'id-username@email.com', name: 'username@email.com' }], + { + customMarkDownRenderers: { + mention: function MyMention(props) { + return ( + + {props.children} + + ); + }, + }, + }, + ); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders standard markdown text', () => { + const Markdown = renderText('Hi, shall we meet on **Tuesday**?', []); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('renders remark-gfm list and strikethrough correctly', () => { + const Markdown = renderText( + 'Pick a time to meet:\n- Wednesday\n- Thursday\n- ~~Sunday~~\n- ~Monday~\n', + [], + ); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it("handles the special case where there's at least one mention and @ symbol at the end", () => { + const Markdown = renderText('@username@email.com @', [ + { id: 'id-username@email.com', name: 'username@email.com' }, + ]); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('handles the special case where there are pronouns in the name', () => { + const Markdown = renderText('hey, @John (they/them), how are you?', [ + { id: 'john', name: 'John (they/them)' }, + ]); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('handles the special case where there is a forward slash in the name', () => { + const Markdown = renderText('hey, @John/Cena, how are you?', [ + { id: 'john', name: 'John/Cena' }, + ]); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('handles the special case where there is a backslash in the name', () => { + const Markdown = renderText('hey, @John\\Cena, how are you?', [ + { id: 'john', name: 'John\\Cena' }, + ]); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchSnapshot(); + }); + + it('parses user mention to default format', () => { + const Markdown = renderText('@username@email.com', [ + { id: 'id-username@email.com', name: 'username@email.com' }, + ]); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchInlineSnapshot(` +

+ + @username@email.com + +

+ `); + }); + + it('allows to override rehype plugins', () => { + const customPlugin = () => (tree) => tree; + const getRehypePlugins = () => [customPlugin]; + const Markdown = renderText( + '@username@email.com', + [{ id: 'id-username@email.com', name: 'username@email.com' }], + { getRehypePlugins }, + ); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchInlineSnapshot(` +

+ @ + + username@email.com + +

+ `); + }); + + it('allows to merge custom rehype plugins followed by default rehype plugins', () => { + const customPlugin = () => (tree) => findAndReplace(tree, /.*@.*/, () => u('text', '#')); + const getRehypePlugins = (defaultPlugins) => [customPlugin, ...defaultPlugins]; + const Markdown = renderText( + '@username@email.com', + [{ id: 'id-username@email.com', name: 'username@email.com' }], + { getRehypePlugins }, + ); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchInlineSnapshot(` +

+ # + + # + +

+ `); + }); + + it('allows to merge default rehype plugins followed by custom rehype plugins', () => { + const customPlugin = () => (tree) => findAndReplace(tree, /.*@.*/, () => u('text', '#')); + const getRehypePlugins = (defaultPlugins) => [...defaultPlugins, customPlugin]; + const Markdown = renderText( + '@username@email.com', + [{ id: 'id-username@email.com', name: 'username@email.com' }], + { getRehypePlugins }, + ); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchInlineSnapshot(` +

+ + # + +

+ `); + }); + + const strikeThroughText = '~~xxx~~'; + it('renders strikethrough', () => { + const Markdown = renderText(strikeThroughText); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchInlineSnapshot(` +

+ + xxx + +

+ `); + }); + + it('allows to override remark plugins', () => { + const customPlugin = () => (tree) => tree; + const getRemarkPlugins = () => [customPlugin]; + const Markdown = renderText(strikeThroughText, [], { getRemarkPlugins }); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchInlineSnapshot(` +

+ ~~xxx~~ +

+ `); + }); + + it('executes remark-gfm before the custom remark plugins are executed', () => { + const replace = () => u('text', '#'); + const customPlugin = () => (tree) => + findAndReplace(tree, new RegExp(strikeThroughText), replace); + + const getRemarkPluginsFirstCustom = (defaultPlugins) => [customPlugin, ...defaultPlugins]; + const getRemarkPluginsFirstDefault = (defaultPlugins) => [...defaultPlugins, customPlugin]; + [getRemarkPluginsFirstCustom, getRemarkPluginsFirstDefault].forEach((getRemarkPlugins) => { + const Markdown = renderText(strikeThroughText, [], { + getRemarkPlugins, + }); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchInlineSnapshot(` +

+ + xxx + +

+ `); + }); + }); + + it('allows to render custom elements', () => { + const customTagName = 'xxx'; + const text = 'a b c'; + const replace = (match) => u('element', { tagName: customTagName }, [u('text', match)]); + const customPlugin = () => (tree) => findAndReplace(tree, /b/, replace); + const getRehypePlugins = (defaultPlugins) => [customPlugin, ...defaultPlugins]; + const Markdown = renderText(text, [], { + allowedTagNames: [...defaultAllowedTagNames, customTagName], + getRehypePlugins, + }); + const tree = renderer.create(Markdown).toJSON(); + expect(tree).toMatchInlineSnapshot(` +

+ a + + b + + c +

+ `); + }); +}); diff --git a/src/components/Message/renderText/index.ts b/src/components/Message/renderText/index.ts new file mode 100644 index 000000000..c655f8e14 --- /dev/null +++ b/src/components/Message/renderText/index.ts @@ -0,0 +1,4 @@ +export { MentionProps } from './Mention'; +export { emojiMarkdownPlugin, mentionsMarkdownPlugin } from './rehypePlugins'; +export { escapeRegExp, matchMarkdownLinks, messageCodeBlocks } from './regex'; +export * from './renderText'; diff --git a/src/components/Message/renderText/regex.ts b/src/components/Message/renderText/regex.ts new file mode 100644 index 000000000..bf01cef2b --- /dev/null +++ b/src/components/Message/renderText/regex.ts @@ -0,0 +1,26 @@ +export function escapeRegExp(text: string) { + return text.replace(/[-[\]{}()*+?.,/\\^$|#]/g, '\\$&'); +} + +export const detectHttp = /(http(s?):\/\/)?(www\.)?/; + +export const messageCodeBlocks = (message: string) => { + const codeRegex = /```[a-z]*\n[\s\S]*?\n```|`[a-z]*[\s\S]*?`/gm; + const matches = message.match(codeRegex); + return matches || []; +}; + +export const matchMarkdownLinks = (message: string) => { + const regexMdLinks = /\[([^[]+)\](\(.*\))/gm; + const matches = message.match(regexMdLinks); + const singleMatch = /\[([^[]+)\]\((.*)\)/; + + const links = matches + ? matches.map((match) => { + const i = singleMatch.exec(match); + return i && [i[1], i[2]]; + }) + : []; + + return links.flat(); +}; diff --git a/src/components/Message/renderText/rehypePlugins.ts b/src/components/Message/renderText/rehypePlugins.ts new file mode 100644 index 000000000..0732da73e --- /dev/null +++ b/src/components/Message/renderText/rehypePlugins.ts @@ -0,0 +1,82 @@ +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'; + +export type HNode = Content | Root; +export const mentionsMarkdownPlugin = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + mentioned_users: UserResponse[], +) => () => { + const mentioned_usernames = mentioned_users + .map((user) => user.name || user.id) + .filter(Boolean) + .map(escapeRegExp); + + const mentionedUsersRegex = new RegExp( + mentioned_usernames.map((username) => `@${username}`).join('|'), + 'g', + ); + + const replace: ReplaceFunction = (match) => { + const usernameOrId = match.replace('@', ''); + const user = mentioned_users.find( + ({ id, name }) => name === usernameOrId || id === usernameOrId, + ); + return u('element', { mentionedUser: user, tagName: 'mention' }, [u('text', match)]); + }; + + const transform = (tree: HNode): HNode => { + if (!mentioned_usernames.length) return tree; + + // handles special cases of mentions where user.name is an e-mail + // Remark GFM translates all e-mail-like text nodes to links creating + // two separate child nodes "@" and "your.name@as.email" instead of + // keeping it as one text node with value "@your.name@as.email" + // this piece finds these two separated nodes and merges them together + // before "replace" function takes over + visit(tree, (node, index, parent) => { + if (index === null) return; + if (!parent) return; + + const nextChild = parent.children.at(index + 1) as Element; + const nextChildHref = nextChild?.properties?.href as string | undefined; + + if ( + node.type === 'text' && + // text value has to have @ sign at the end of the string + // and no other characters except whitespace can precede it + // valid cases: "text @", "@", " @" + // invalid cases: "text@", "@text", + /.?\s?@$|^@$/.test(node.value) && + nextChildHref?.startsWith('mailto:') + ) { + const newTextValue = node.value.replace(/@$/, ''); + const username = nextChildHref.replace('mailto:', ''); + parent.children[index] = u('text', newTextValue); + parent.children[index + 1] = u('text', `@${username}`); + } + }); + + return findAndReplace(tree, mentionedUsersRegex, replace); + }; + + 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/renderText.tsx b/src/components/Message/renderText/renderText.tsx new file mode 100644 index 000000000..afbc8f66f --- /dev/null +++ b/src/components/Message/renderText/renderText.tsx @@ -0,0 +1,192 @@ +import React, { ComponentType } from 'react'; +import ReactMarkdown, { Options, uriTransformer } from 'react-markdown'; +import { find } from 'linkifyjs'; +import uniqBy from 'lodash.uniqby'; +import remarkGfm from 'remark-gfm'; + +import { Emoji } from './Emoji'; +import { Anchor } from './Anchor'; +import { Mention, MentionProps } from './Mention'; +import { detectHttp, escapeRegExp, matchMarkdownLinks, messageCodeBlocks } from './regex'; +import { emojiMarkdownPlugin, mentionsMarkdownPlugin } from './rehypePlugins'; + +import type { ReactMarkdownProps } from 'react-markdown/lib/complex-types'; +import type { PluggableList } from 'react-markdown/lib/react-markdown'; +import type { UserResponse } from 'stream-chat'; +import type { DefaultStreamChatGenerics } from '../../../types/types'; + +export type RenderTextPluginConfigurator = (defaultPlugins: PluggableList) => PluggableList; + +export const defaultAllowedTagNames: Array = [ + 'html', + 'text', + 'br', + 'p', + 'em', + 'strong', + 'a', + 'ol', + 'ul', + 'li', + 'code', + 'pre', + 'blockquote', + 'del', + // custom types (tagNames) + 'emoji', + 'mention', +]; + +function formatUrlForDisplay(url: string) { + try { + return decodeURIComponent(url).replace(detectHttp, ''); + } catch (e) { + return url; + } +} + +function encodeDecode(url: string) { + try { + return encodeURI(decodeURIComponent(url)); + } catch (error) { + return url; + } +} + +const transformLinkUri = (uri: string) => (uri.startsWith('app://') ? uri : uriTransformer(uri)); + +const getPluginsForward: RenderTextPluginConfigurator = (plugins: PluggableList) => plugins; + +export const markDownRenderers: RenderTextOptions['customMarkDownRenderers'] = { + a: Anchor, + emoji: Emoji, + mention: Mention, +}; + +export type RenderTextOptions< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +> = { + // eslint-disable-next-line @typescript-eslint/ban-types + allowedTagNames?: Array; + customMarkDownRenderers?: Options['components'] & + Partial<{ + emoji: ComponentType; + mention: ComponentType>; + }>; + getRehypePlugins?: RenderTextPluginConfigurator; + getRemarkPlugins?: RenderTextPluginConfigurator; +}; + +export const renderText = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics +>( + text?: string, + mentionedUsers?: UserResponse[], + { + allowedTagNames = defaultAllowedTagNames, + customMarkDownRenderers, + getRehypePlugins = getPluginsForward, + getRemarkPlugins = getPluginsForward, + }: RenderTextOptions = {}, +) => { + // take the @ mentions and turn them into markdown? + // translate links + if (!text) return null; + if (text.trim().length === 1) return <>{text}; + + let newText = text; + const markdownLinks = matchMarkdownLinks(newText); + const codeBlocks = messageCodeBlocks(newText); + + // extract all valid links/emails within text and replace it with proper markup + uniqBy([...find(newText, 'email'), ...find(newText, 'url')], 'value').forEach( + ({ href, type, value }) => { + const linkIsInBlock = codeBlocks.some((block) => block?.includes(value)); + + // check if message is already markdown + const noParsingNeeded = + markdownLinks && + markdownLinks.filter((text) => { + const strippedHref = href?.replace(detectHttp, ''); + const strippedText = text?.replace(detectHttp, ''); + + if (!strippedHref || !strippedText) return false; + + return strippedHref.includes(strippedText) || strippedText.includes(strippedHref); + }); + + if (noParsingNeeded.length > 0 || linkIsInBlock) return; + + try { + // special case for mentions: + // it could happen that a user's name matches with an e-mail format pattern. + // in that case, we check whether the found e-mail is actually a mention + // by naively checking for an existence of @ sign in front of it. + if (type === 'email' && mentionedUsers) { + const emailMatchesWithName = mentionedUsers.some((u) => u.name === value); + if (emailMatchesWithName) { + newText = newText.replace(new RegExp(escapeRegExp(value), 'g'), (match, position) => { + const isMention = newText.charAt(position - 1) === '@'; + // in case of mention, we leave the match in its original form, + // and we let `mentionsMarkdownPlugin` to do its job + return isMention ? match : `[${match}](${encodeDecode(href)})`; + }); + + return; + } + } + + const displayLink = type === 'email' ? value : formatUrlForDisplay(href); + + newText = newText.replace( + new RegExp(escapeRegExp(value), 'g'), + `[${displayLink}](${encodeDecode(href)})`, + ); + } catch (e) { + void e; + } + }, + ); + + const remarkPlugins: PluggableList = [[remarkGfm, { singleTilde: false }]]; + const rehypePlugins = [emojiMarkdownPlugin]; + + if (mentionedUsers?.length) { + rehypePlugins.push(mentionsMarkdownPlugin(mentionedUsers)); + } + + // TODO: remove in the next major release + if (customMarkDownRenderers?.mention) { + const MentionComponent = customMarkDownRenderers['mention']; + + // eslint-disable-next-line react/display-name + customMarkDownRenderers['mention'] = ({ node, ...rest }) => ( + + ); + } + + const rehypeComponents = { + ...markDownRenderers, + ...customMarkDownRenderers, + }; + + return ( + + {newText} + + ); +}; diff --git a/src/components/Message/types.ts b/src/components/Message/types.ts index 7cc9f495f..f7edec4c8 100644 --- a/src/components/Message/types.ts +++ b/src/components/Message/types.ts @@ -11,8 +11,8 @@ import type { ChannelActionContextValue } from '../../context/ChannelActionConte import type { StreamMessage } from '../../context/ChannelStateContext'; import type { ComponentContextValue } from '../../context/ComponentContext'; import type { MessageContextValue } from '../../context/MessageContext'; -import type { RenderTextOptions } from '../../utils'; +import type { RenderTextOptions } from './renderText'; import type { CustomTrigger, DefaultStreamChatGenerics } from '../../types/types'; export type ReactEventHandler = (event: React.BaseSyntheticEvent) => Promise | void; diff --git a/src/components/Message/utils.tsx b/src/components/Message/utils.tsx index 3ef1dc925..e6649b413 100644 --- a/src/components/Message/utils.tsx +++ b/src/components/Message/utils.tsx @@ -1,4 +1,5 @@ import deepequal from 'react-fast-compare'; +import emojiRegex from 'emoji-regex'; import type { TFunction } from 'i18next'; import type { MessageResponse, Mute, StreamChat, UserResponse } from 'stream-chat'; @@ -403,3 +404,12 @@ export const getReadByTooltipText = < return outStr; }; + +export const isOnlyEmojis = (text?: string) => { + if (!text) return false; + + const noEmojis = text.replace(emojiRegex(), ''); + const noSpace = noEmojis.replace(/[\s\n]/gm, ''); + + return !noSpace; +}; diff --git a/src/context/MessageContext.tsx b/src/context/MessageContext.tsx index 4645e9cd2..5622207ba 100644 --- a/src/context/MessageContext.tsx +++ b/src/context/MessageContext.tsx @@ -11,8 +11,8 @@ import type { ReactEventHandler } from '../components/Message/types'; import type { MessageActionsArray } from '../components/Message/utils'; import type { MessageInputProps } from '../components/MessageInput/MessageInput'; import type { GroupStyle } from '../components/MessageList/utils'; -import type { RenderTextOptions } from '../utils'; +import type { RenderTextOptions } from '../components/Message/renderText'; import type { DefaultStreamChatGenerics, UnknownType } from '../types/types'; export type CustomMessageActions< diff --git a/src/index.ts b/src/index.ts index a58b52f03..773f33e16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ export * from './components'; export * from './context'; export * from './i18n'; -// todo: distribute utils into separate files export * from './utils'; -export { getChannel } from './utils/getChannel'; diff --git a/src/utils.tsx b/src/utils.tsx deleted file mode 100644 index ce1c4f05b..000000000 --- a/src/utils.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import React, { ComponentProps, ComponentType } from 'react'; -import emojiRegex from 'emoji-regex'; -import { find } from 'linkifyjs'; -import { nanoid } from 'nanoid'; -import { findAndReplace, ReplaceFunction } from 'hast-util-find-and-replace'; -import ReactMarkdown, { Options, uriTransformer } from 'react-markdown'; -import { u } from 'unist-builder'; -import { visit } from 'unist-util-visit'; - -import remarkGfm from 'remark-gfm'; -import uniqBy from 'lodash.uniqby'; -import clsx from 'clsx'; - -import type { Element } from 'react-markdown/lib/ast-to-react'; -import type { ReactMarkdownProps } from 'react-markdown/lib/complex-types'; -import type { Content, Root } from 'hast'; -import type { UserResponse } from 'stream-chat'; -import type { DefaultStreamChatGenerics } from './types/types'; - -export const isOnlyEmojis = (text?: string) => { - if (!text) return false; - - const noEmojis = text.replace(emojiRegex(), ''); - const noSpace = noEmojis.replace(/[\s\n]/gm, ''); - - return !noSpace; -}; - -const allowedMarkups: Array = [ - 'html', - 'text', - 'br', - 'p', - 'em', - 'strong', - 'a', - 'ol', - 'ul', - 'li', - 'code', - 'pre', - 'blockquote', - 'del', - // custom types (tagNames) - 'emoji', - 'mention', -]; - -type HNode = Content | Root; - -export const matchMarkdownLinks = (message: string) => { - const regexMdLinks = /\[([^[]+)\](\(.*\))/gm; - const matches = message.match(regexMdLinks); - const singleMatch = /\[([^[]+)\]\((.*)\)/; - - const links = matches - ? matches.map((match) => { - const i = singleMatch.exec(match); - return i && [i[1], i[2]]; - }) - : []; - - return links.flat(); -}; - -export const messageCodeBlocks = (message: string) => { - const codeRegex = /```[a-z]*\n[\s\S]*?\n```|`[a-z]*[\s\S]*?`/gm; - const matches = message.match(codeRegex); - return matches || []; -}; - -const detectHttp = /(http(s?):\/\/)?(www\.)?/; - -function formatUrlForDisplay(url: string) { - try { - return decodeURIComponent(url).replace(detectHttp, ''); - } catch (e) { - return url; - } -} - -function encodeDecode(url: string) { - try { - return encodeURI(decodeURIComponent(url)); - } catch (error) { - return url; - } -} - -const Anchor = ({ children, href }: ComponentProps<'a'> & ReactMarkdownProps) => { - const isEmail = href?.startsWith('mailto:'); - const isUrl = href?.startsWith('http'); - - if (!href || (!isEmail && !isUrl)) return <>{children}; - - return ( - - {children} - - ); -}; - -const Emoji = ({ children }: ReactMarkdownProps) => ( - - {children} - -); - -export type MentionProps< - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics -> = ReactMarkdownProps & { - /** - * @deprecated will be removed in the next major release, transition to using `node.mentionedUser` instead - */ - mentioned_user: UserResponse; - node: { - /** - * @deprecated will be removed in the next major release, transition to using `node.mentionedUser` instead - */ - mentioned_user: UserResponse; - mentionedUser: UserResponse; - }; -}; - -const Mention = ({ - children, - node: { mentionedUser }, -}: MentionProps) => ( - - {children} - -); - -export const markDownRenderers: RenderTextOptions['customMarkDownRenderers'] = { - a: Anchor, - emoji: Emoji, - mention: Mention, -}; - -export const emojiMarkdownPlugin = () => { - const replace: ReplaceFunction = (match) => - u('element', { tagName: 'emoji' }, [u('text', match)]); - - const transform = (node: HNode) => findAndReplace(node, emojiRegex(), replace); - - return transform; -}; - -export const mentionsMarkdownPlugin = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics ->( - mentioned_users: UserResponse[], -) => () => { - const mentioned_usernames = mentioned_users - .map((user) => user.name || user.id) - .filter(Boolean) - .map(escapeRegExp); - - const mentionedUsersRegex = new RegExp( - mentioned_usernames.map((username) => `@${username}`).join('|'), - 'g', - ); - - const replace: ReplaceFunction = (match) => { - const usernameOrId = match.replace('@', ''); - const user = mentioned_users.find( - ({ id, name }) => name === usernameOrId || id === usernameOrId, - ); - return u('element', { mentionedUser: user, tagName: 'mention' }, [u('text', match)]); - }; - - const transform = (tree: HNode): HNode => { - if (!mentioned_usernames.length) return tree; - - // handles special cases of mentions where user.name is an e-mail - // Remark GFM translates all e-mail-like text nodes to links creating - // two separate child nodes "@" and "your.name@as.email" instead of - // keeping it as one text node with value "@your.name@as.email" - // this piece finds these two separated nodes and merges them together - // before "replace" function takes over - visit(tree, (node, index, parent) => { - if (index === null) return; - if (!parent) return; - - const nextChild = parent.children.at(index + 1) as Element; - const nextChildHref = nextChild?.properties?.href as string | undefined; - - if ( - node.type === 'text' && - // text value has to have @ sign at the end of the string - // and no other characters except whitespace can precede it - // valid cases: "text @", "@", " @" - // invalid cases: "text@", "@text", - /.?\s?@$|^@$/.test(node.value) && - nextChildHref?.startsWith('mailto:') - ) { - const newTextValue = node.value.replace(/@$/, ''); - const username = nextChildHref.replace('mailto:', ''); - parent.children[index] = u('text', newTextValue); - parent.children[index + 1] = u('text', `@${username}`); - } - }); - - return findAndReplace(tree, mentionedUsersRegex, replace); - }; - - return transform; -}; - -export type RenderTextOptions< - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics -> = { - customMarkDownRenderers?: Options['components'] & - Partial<{ - emoji: ComponentType; - mention: ComponentType>; - }>; -}; - -export const renderText = < - StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics ->( - text?: string, - mentionedUsers?: UserResponse[], - { customMarkDownRenderers }: RenderTextOptions = {}, -) => { - // take the @ mentions and turn them into markdown? - // translate links - if (!text) return null; - if (text.trim().length === 1) return <>{text}; - - let newText = text; - const markdownLinks = matchMarkdownLinks(newText); - const codeBlocks = messageCodeBlocks(newText); - - // extract all valid links/emails within text and replace it with proper markup - uniqBy([...find(newText, 'email'), ...find(newText, 'url')], 'value').forEach( - ({ href, type, value }) => { - const linkIsInBlock = codeBlocks.some((block) => block?.includes(value)); - - // check if message is already markdown - const noParsingNeeded = - markdownLinks && - markdownLinks.filter((text) => { - const strippedHref = href?.replace(detectHttp, ''); - const strippedText = text?.replace(detectHttp, ''); - - if (!strippedHref || !strippedText) return false; - - return strippedHref.includes(strippedText) || strippedText.includes(strippedHref); - }); - - if (noParsingNeeded.length > 0 || linkIsInBlock) return; - - try { - // special case for mentions: - // it could happen that a user's name matches with an e-mail format pattern. - // in that case, we check whether the found e-mail is actually a mention - // by naively checking for an existence of @ sign in front of it. - if (type === 'email' && mentionedUsers) { - const emailMatchesWithName = mentionedUsers.some((u) => u.name === value); - if (emailMatchesWithName) { - newText = newText.replace(new RegExp(escapeRegExp(value), 'g'), (match, position) => { - const isMention = newText.charAt(position - 1) === '@'; - // in case of mention, we leave the match in its original form, - // and we let `mentionsMarkdownPlugin` to do its job - return isMention ? match : `[${match}](${encodeDecode(href)})`; - }); - - return; - } - } - - const displayLink = type === 'email' ? value : formatUrlForDisplay(href); - - newText = newText.replace( - new RegExp(escapeRegExp(value), 'g'), - `[${displayLink}](${encodeDecode(href)})`, - ); - } catch (e) { - void e; - } - }, - ); - - const rehypePlugins = [emojiMarkdownPlugin]; - - if (mentionedUsers?.length) { - rehypePlugins.push(mentionsMarkdownPlugin(mentionedUsers)); - } - - // TODO: remove in the next major release - if (customMarkDownRenderers?.mention) { - const MentionComponent = customMarkDownRenderers['mention']; - - // eslint-disable-next-line react/display-name - customMarkDownRenderers['mention'] = ({ node, ...rest }) => ( - - ); - } - - const rehypeComponents = { - ...markDownRenderers, - ...customMarkDownRenderers, - }; - - return ( - (uri.startsWith('app://') ? uri : uriTransformer(uri))} - unwrapDisallowed - > - {newText} - - ); -}; - -export function escapeRegExp(text: string) { - return text.replace(/[-[\]{}()*+?.,/\\^$|#]/g, '\\$&'); -} - -/** - * @deprecated will be removed in the next major release - */ -export const generateRandomId = nanoid; - -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/charAt#getting_whole_characters -export const getWholeChar = (str: string, i: number) => { - const code = str.charCodeAt(i); - - if (Number.isNaN(code)) return ''; - - if (code < 0xd800 || code > 0xdfff) return str.charAt(i); - - if (0xd800 <= code && code <= 0xdbff) { - if (str.length <= i + 1) { - throw 'High surrogate without following low surrogate'; - } - - const next = str.charCodeAt(i + 1); - - if (0xdc00 > next || next > 0xdfff) { - throw 'High surrogate without following low surrogate'; - } - - return str.charAt(i) + str.charAt(i + 1); - } - - if (i === 0) { - throw 'Low surrogate without preceding high surrogate'; - } - - const prev = str.charCodeAt(i - 1); - - if (0xd800 > prev || prev > 0xdbff) { - throw 'Low surrogate without preceding high surrogate'; - } - - return ''; -}; diff --git a/src/utils/generateRandomId.ts b/src/utils/generateRandomId.ts new file mode 100644 index 000000000..2bd2d5473 --- /dev/null +++ b/src/utils/generateRandomId.ts @@ -0,0 +1,6 @@ +import { nanoid } from 'nanoid'; + +/** + * @deprecated will be removed in the next major release + */ +export const generateRandomId = nanoid; diff --git a/src/utils/getWholeChar.ts b/src/utils/getWholeChar.ts new file mode 100644 index 000000000..85472f1e7 --- /dev/null +++ b/src/utils/getWholeChar.ts @@ -0,0 +1,34 @@ +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/charAt#getting_whole_characters +export const getWholeChar = (str: string, i: number) => { + const code = str.charCodeAt(i); + + if (Number.isNaN(code)) return ''; + + if (code < 0xd800 || code > 0xdfff) return str.charAt(i); + + if (0xd800 <= code && code <= 0xdbff) { + if (str.length <= i + 1) { + throw 'High surrogate without following low surrogate'; + } + + const next = str.charCodeAt(i + 1); + + if (0xdc00 > next || next > 0xdfff) { + throw 'High surrogate without following low surrogate'; + } + + return str.charAt(i) + str.charAt(i + 1); + } + + if (i === 0) { + throw 'Low surrogate without preceding high surrogate'; + } + + const prev = str.charCodeAt(i - 1); + + if (0xd800 > prev || prev > 0xdbff) { + throw 'Low surrogate without preceding high surrogate'; + } + + return ''; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 000000000..843742040 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './generateRandomId'; +export * from './getChannel'; +export * from './getWholeChar';