From 396852a42bca79794c5398517ca0a2a044f9176e Mon Sep 17 00:00:00 2001 From: martincupela Date: Mon, 23 Oct 2023 13:43:43 +0200 Subject: [PATCH 1/5] refactor: move message text rendering functionality to Message folder --- src/components/AutoCompleteTextarea/List.jsx | 2 +- src/components/Avatar/Avatar.tsx | 3 +- src/components/Message/FixedHeightMessage.tsx | 2 +- src/components/Message/MessageText.tsx | 4 +- src/components/Message/index.ts | 1 + src/components/Message/renderText/Anchor.tsx | 21 + src/components/Message/renderText/Emoji.tsx | 8 + src/components/Message/renderText/Mention.tsx | 31 ++ .../__snapshots__/renderText.test.js.snap} | 0 .../renderText/__tests__/renderText.test.js} | 2 +- src/components/Message/renderText/index.ts | 4 + src/components/Message/renderText/regex.ts | 26 ++ .../Message/renderText/rehypePlugins.ts | 82 ++++ .../Message/renderText/renderText.tsx | 175 ++++++++ src/components/Message/types.ts | 2 +- src/components/Message/utils.tsx | 10 + src/context/MessageContext.tsx | 2 +- src/index.ts | 2 - src/utils.tsx | 375 ------------------ src/utils/generateRandomId.ts | 6 + src/utils/getWholeChar.ts | 34 ++ src/utils/index.ts | 3 + 22 files changed, 409 insertions(+), 386 deletions(-) create mode 100644 src/components/Message/renderText/Anchor.tsx create mode 100644 src/components/Message/renderText/Emoji.tsx create mode 100644 src/components/Message/renderText/Mention.tsx rename src/{__tests__/__snapshots__/utils.test.js.snap => components/Message/renderText/__tests__/__snapshots__/renderText.test.js.snap} (100%) rename src/{__tests__/utils.test.js => components/Message/renderText/__tests__/renderText.test.js} (98%) create mode 100644 src/components/Message/renderText/index.ts create mode 100644 src/components/Message/renderText/regex.ts create mode 100644 src/components/Message/renderText/rehypePlugins.ts create mode 100644 src/components/Message/renderText/renderText.tsx delete mode 100644 src/utils.tsx create mode 100644 src/utils/generateRandomId.ts create mode 100644 src/utils/getWholeChar.ts create mode 100644 src/utils/index.ts 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/__tests__/utils.test.js b/src/components/Message/renderText/__tests__/renderText.test.js similarity index 98% rename from src/__tests__/utils.test.js rename to src/components/Message/renderText/__tests__/renderText.test.js index 8099a50e4..4ab6b7927 100644 --- a/src/__tests__/utils.test.js +++ b/src/components/Message/renderText/__tests__/renderText.test.js @@ -1,6 +1,6 @@ import React from 'react'; import renderer from 'react-test-renderer'; -import { renderText } from '../utils'; +import { renderText } from '../renderText'; describe(`renderText`, () => { it('handles the special case where user name matches to an e-mail pattern - 1', () => { 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..3fa6cf89f --- /dev/null +++ b/src/components/Message/renderText/renderText.tsx @@ -0,0 +1,175 @@ +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 { UserResponse } from 'stream-chat'; +import type { DefaultStreamChatGenerics } from '../../../types/types'; + +const allowedMarkups: 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; + } +} + +export const markDownRenderers: RenderTextOptions['customMarkDownRenderers'] = { + a: Anchor, + emoji: Emoji, + mention: Mention, +}; + +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} + + ); +}; 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'; From 16b2592e5e0dccc823e490ab2aab07fa8ca9388c Mon Sep 17 00:00:00 2001 From: martincupela Date: Tue, 24 Oct 2023 09:26:55 +0200 Subject: [PATCH 2/5] feat: allow to override default remark and rehype plugins for markdown parsing --- .../renderText/__tests__/renderText.test.js | 135 ++++++++++++++++++ .../Message/renderText/renderText.tsx | 20 ++- 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/src/components/Message/renderText/__tests__/renderText.test.js b/src/components/Message/renderText/__tests__/renderText.test.js index 4ab6b7927..79040f1b4 100644 --- a/src/components/Message/renderText/__tests__/renderText.test.js +++ b/src/components/Message/renderText/__tests__/renderText.test.js @@ -1,6 +1,8 @@ import React from 'react'; import renderer from 'react-test-renderer'; import { 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', () => { @@ -112,4 +114,137 @@ describe(`renderText`, () => { 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 + +

+ `); + }); + }); }); diff --git a/src/components/Message/renderText/renderText.tsx b/src/components/Message/renderText/renderText.tsx index 3fa6cf89f..db25682c8 100644 --- a/src/components/Message/renderText/renderText.tsx +++ b/src/components/Message/renderText/renderText.tsx @@ -11,6 +11,7 @@ import { detectHttp, escapeRegExp, matchMarkdownLinks, messageCodeBlocks } from 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'; @@ -50,6 +51,10 @@ function encodeDecode(url: string) { } } +const transformLinkUri = (uri: string) => (uri.startsWith('app://') ? uri : uriTransformer(uri)); + +const getPluginsForward = (plugins: PluggableList) => plugins; + export const markDownRenderers: RenderTextOptions['customMarkDownRenderers'] = { a: Anchor, emoji: Emoji, @@ -64,6 +69,8 @@ export type RenderTextOptions< emoji: ComponentType; mention: ComponentType>; }>; + getRehypePlugins?: (defaultPlugins: PluggableList) => PluggableList; + getRemarkPlugins?: (defaultPlugins: PluggableList) => PluggableList; }; export const renderText = < @@ -71,7 +78,11 @@ export const renderText = < >( text?: string, mentionedUsers?: UserResponse[], - { customMarkDownRenderers }: RenderTextOptions = {}, + { + customMarkDownRenderers, + getRehypePlugins = getPluginsForward, + getRemarkPlugins = getPluginsForward, + }: RenderTextOptions = {}, ) => { // take the @ mentions and turn them into markdown? // translate links @@ -132,6 +143,7 @@ export const renderText = < }, ); + const remarkPlugins: PluggableList = [[remarkGfm, { singleTilde: false }]]; const rehypePlugins = [emojiMarkdownPlugin]; if (mentionedUsers?.length) { @@ -163,10 +175,10 @@ export const renderText = < (uri.startsWith('app://') ? uri : uriTransformer(uri))} + transformLinkUri={transformLinkUri} unwrapDisallowed > {newText} From 1b5cce60bf49af3af2e81a0cbebce88889440b2e Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Oct 2023 14:07:11 +0200 Subject: [PATCH 3/5] docs: document the use of custom rehype and remark plugin configuration functions --- .../components/contexts/message-context.mdx | 2 +- .../core-components/message-list.mdx | 52 +++++++++++++++++-- .../message-components/message-ui.mdx | 2 +- .../components/message-components/message.mdx | 2 +- .../Message/renderText/renderText.tsx | 8 +-- 5 files changed, 56 insertions(+), 10 deletions(-) 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/components/Message/renderText/renderText.tsx b/src/components/Message/renderText/renderText.tsx index db25682c8..768072ad7 100644 --- a/src/components/Message/renderText/renderText.tsx +++ b/src/components/Message/renderText/renderText.tsx @@ -15,6 +15,8 @@ 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; + const allowedMarkups: Array = [ 'html', 'text', @@ -53,7 +55,7 @@ function encodeDecode(url: string) { const transformLinkUri = (uri: string) => (uri.startsWith('app://') ? uri : uriTransformer(uri)); -const getPluginsForward = (plugins: PluggableList) => plugins; +const getPluginsForward: RenderTextPluginConfigurator = (plugins: PluggableList) => plugins; export const markDownRenderers: RenderTextOptions['customMarkDownRenderers'] = { a: Anchor, @@ -69,8 +71,8 @@ export type RenderTextOptions< emoji: ComponentType; mention: ComponentType>; }>; - getRehypePlugins?: (defaultPlugins: PluggableList) => PluggableList; - getRemarkPlugins?: (defaultPlugins: PluggableList) => PluggableList; + getRehypePlugins?: RenderTextPluginConfigurator; + getRemarkPlugins?: RenderTextPluginConfigurator; }; export const renderText = < From 3268183fe43913a66165af7073a14449b881d0fd Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 25 Oct 2023 15:00:07 +0200 Subject: [PATCH 4/5] feat: allow to define own set of allowed tag names in the rendered message text --- .../renderText/__tests__/renderText.test.js | 24 ++++++++++++++++++- .../Message/renderText/renderText.tsx | 6 +++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/components/Message/renderText/__tests__/renderText.test.js b/src/components/Message/renderText/__tests__/renderText.test.js index 79040f1b4..f0d5631da 100644 --- a/src/components/Message/renderText/__tests__/renderText.test.js +++ b/src/components/Message/renderText/__tests__/renderText.test.js @@ -1,6 +1,6 @@ import React from 'react'; import renderer from 'react-test-renderer'; -import { renderText } from '../renderText'; +import { defaultAllowedTagNames, renderText } from '../renderText'; import { findAndReplace } from 'hast-util-find-and-replace'; import { u } from 'unist-builder'; @@ -247,4 +247,26 @@ describe(`renderText`, () => { `); }); }); + + 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/renderText.tsx b/src/components/Message/renderText/renderText.tsx index 768072ad7..346374b05 100644 --- a/src/components/Message/renderText/renderText.tsx +++ b/src/components/Message/renderText/renderText.tsx @@ -17,7 +17,7 @@ import type { DefaultStreamChatGenerics } from '../../../types/types'; export type RenderTextPluginConfigurator = (defaultPlugins: PluggableList) => PluggableList; -const allowedMarkups: Array = [ +export const defaultAllowedTagNames: Array = [ 'html', 'text', 'br', @@ -66,6 +66,7 @@ export const markDownRenderers: RenderTextOptions['customMarkDownRenderers'] = { export type RenderTextOptions< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics > = { + allowedTagNames?: Array; customMarkDownRenderers?: Options['components'] & Partial<{ emoji: ComponentType; @@ -81,6 +82,7 @@ export const renderText = < text?: string, mentionedUsers?: UserResponse[], { + allowedTagNames = defaultAllowedTagNames, customMarkDownRenderers, getRehypePlugins = getPluginsForward, getRemarkPlugins = getPluginsForward, @@ -175,7 +177,7 @@ export const renderText = < return ( Date: Wed, 25 Oct 2023 15:55:09 +0200 Subject: [PATCH 5/5] feat: keep intellisense for RenderTextOptions.allowedTagNames --- src/components/Message/renderText/renderText.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Message/renderText/renderText.tsx b/src/components/Message/renderText/renderText.tsx index 346374b05..afbc8f66f 100644 --- a/src/components/Message/renderText/renderText.tsx +++ b/src/components/Message/renderText/renderText.tsx @@ -66,7 +66,8 @@ export const markDownRenderers: RenderTextOptions['customMarkDownRenderers'] = { export type RenderTextOptions< StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics > = { - allowedTagNames?: Array; + // eslint-disable-next-line @typescript-eslint/ban-types + allowedTagNames?: Array; customMarkDownRenderers?: Options['components'] & Partial<{ emoji: ComponentType;