) => (
+
+ {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';