diff --git a/lib/shared/markdown.js b/lib/shared/markdown.js index 72689f3ec..14b713b01 100644 --- a/lib/shared/markdown.js +++ b/lib/shared/markdown.js @@ -1,378 +1,407 @@ // @flow import invariant from 'invariant'; +import * as React from 'react'; import { markdownUserMentionRegex, decodeChatMentionText, } from './mention-utils.js'; +import { useENSNames } from '../hooks/ens-cache.js'; import type { ChatMentionCandidates, RelativeMemberInfo, ResolvedThreadInfo, } from '../types/thread-types.js'; // simple-markdown types export type State = { key?: string | number | void, inline?: ?boolean, [string]: any, }; export type Parser = (source: string, state?: ?State) => Array; export type Capture = | (Array & { +index: number, ... }) | (Array & { +index?: number, ... }); export type SingleASTNode = { type: string, [string]: any, }; export type ASTNode = SingleASTNode | Array; type UnTypedASTNode = { [string]: any, ... }; type MatchFunction = { regex?: RegExp, ... } & (( source: string, state: State, prevCapture: string, ) => ?Capture); export type ReactElement = React$Element; type ReactElements = React$Node; export type Output = (node: ASTNode, state?: ?State) => Result; type ArrayNodeOutput = ( node: Array, nestedOutput: Output, state: State, ) => Result; type ArrayRule = { +react?: ArrayNodeOutput, +html?: ArrayNodeOutput, +[string]: ArrayNodeOutput, }; type ParseFunction = ( capture: Capture, nestedParse: Parser, state: State, ) => UnTypedASTNode | ASTNode; type ParserRule = { +order: number, +match: MatchFunction, +quality?: (capture: Capture, state: State, prevCapture: string) => number, +parse: ParseFunction, ... }; export type ParserRules = { +Array?: ArrayRule, +[type: string]: ParserRule, ... }; const paragraphRegex: RegExp = /^((?:[^\n]*)(?:\n|$))/; const paragraphStripTrailingNewlineRegex: RegExp = /^([^\n]*)(?:\n|$)/; const headingRegex: RegExp = /^ *(#{1,6}) ([^\n]+?)#* *(?![^\n])/; const headingStripFollowingNewlineRegex: RegExp = /^ *(#{1,6}) ([^\n]+?)#* *(?:\n|$)/; const fenceRegex: RegExp = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?\n)\1(?:\n|$)/; const fenceStripTrailingNewlineRegex: RegExp = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1(?:\n|$)/; const codeBlockRegex: RegExp = /^(?: {4}[^\n]*\n*?)+(?!\n* {4}[^\n])(?:\n|$)/; const codeBlockStripTrailingNewlineRegex: RegExp = /^((?: {4}[^\n]*\n*?)+)(?!\n* {4}[^\n])(?:\n|$)/; const urlRegex: RegExp = /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/i; export type JSONCapture = Array & { +json: Object, +index?: void, ... }; function jsonMatch(source: string): ?JSONCapture { if (!source.startsWith('{')) { return null; } let jsonString = ''; let counter = 0; for (let i = 0; i < source.length; i++) { const char = source[i]; jsonString += char; if (char === '{') { counter++; } else if (char === '}') { counter--; } if (counter === 0) { break; } } if (counter !== 0) { return null; } let json; try { json = JSON.parse(jsonString); } catch { return null; } if (!json || typeof json !== 'object') { return null; } return { ...([jsonString]: any), json }; } function jsonPrint(capture: JSONCapture): string { return JSON.stringify(capture.json, null, ' '); } const listRegex = /^( *)([*+-]|\d+\.) ([\s\S]+?)(?:\n{2}|\s*\n*$)/; const listItemRegex = /^( *)([*+-]|\d+\.) [^\n]*(?:\n(?!\1(?:[*+-]|\d+\.) )[^\n]*)*(\n|$)/gm; const listItemPrefixRegex = /^( *)([*+-]|\d+\.) /; const listLookBehindRegex = /(?:^|\n)( *)$/; function matchList(source: string, state: State): RegExp$matchResult | null { if (state.inline) { return null; } const prevCaptureStr = state.prevCapture ? state.prevCapture[0] : ''; const isStartOfLineCapture = listLookBehindRegex.exec(prevCaptureStr); if (!isStartOfLineCapture) { return null; } const fullSource = isStartOfLineCapture[1] + source; return listRegex.exec(fullSource); } // We've defined our own parse function for lists because simple-markdown // handles newlines differently. Outside of that our implementation is fairly // similar. For more details about list parsing works, take a look at the // comments in the simple-markdown package function parseList( capture: Capture, parse: Parser, state: State, ): UnTypedASTNode { const bullet = capture[2]; const ordered = bullet.length > 1; const start = ordered ? Number(bullet) : undefined; const items = capture[0].match(listItemRegex); let itemContent = null; if (items) { itemContent = items.map((item: string) => { const prefixCapture = listItemPrefixRegex.exec(item); const space = prefixCapture ? prefixCapture[0].length : 0; const spaceRegex = new RegExp('^ {1,' + space + '}', 'gm'); const content: string = item .replace(spaceRegex, '') .replace(listItemPrefixRegex, ''); // We're handling this different than simple-markdown - // each item is a paragraph return parse(content, state); }); } return { ordered: ordered, start: start, items: itemContent, }; } -function createMemberMapForUserMentions( +const useENSNamesOptions = { allAtOnce: true }; +function useMemberMapForUserMentions( members: $ReadOnlyArray, ): $ReadOnlyMap { - const membersMap = new Map(); - members.forEach(member => { - if (member.role && member.username) { - membersMap.set(member.username.toLowerCase(), member.id); + const membersWithRole = React.useMemo( + () => members.filter(member => member.role), + [members], + ); + + const resolvedMembers = useENSNames(membersWithRole, useENSNamesOptions); + const resolvedMembersMap: $ReadOnlyMap = + React.useMemo( + () => new Map(resolvedMembers.map(member => [member.id, member])), + [resolvedMembers], + ); + + const membersMap = React.useMemo(() => { + const map = new Map(); + + for (const member of membersWithRole) { + const rawUsername = member.username; + + if (rawUsername) { + map.set(rawUsername.toLowerCase(), member.id); + } + + const resolvedMember = resolvedMembersMap.get(member.id); + const resolvedUsername = resolvedMember?.username; + + if (resolvedUsername && resolvedUsername !== rawUsername) { + map.set(resolvedUsername.toLowerCase(), member.id); + } } - }); + + return map; + }, [membersWithRole, resolvedMembersMap]); return membersMap; } function matchUserMentions( membersMap: $ReadOnlyMap, ): MatchFunction { const match = (source: string, state: State) => { if (!state.inline) { return null; } const result = markdownUserMentionRegex.exec(source); if (!result) { return null; } const username = result[2]; invariant(username, 'markdownMentionRegex should match two capture groups'); if (!membersMap.has(username.toLowerCase())) { return null; } return result; }; match.regex = markdownUserMentionRegex; return match; } type ParsedUserMention = { +content: string, +userID: string, }; function parseUserMentions( membersMap: $ReadOnlyMap, capture: Capture, ): ParsedUserMention { const memberUsername = capture[2]; const memberID = membersMap.get(memberUsername.toLowerCase()); invariant(memberID, 'memberID should be set'); return { content: capture[0], userID: memberID, }; } function parseChatMention( chatMentionCandidates: ChatMentionCandidates, capture: Capture, ): { threadInfo: ?ResolvedThreadInfo, content: string, hasAccessToChat: boolean, } { const threadInfo = chatMentionCandidates[capture[3]]; const threadName = threadInfo?.uiName ?? decodeChatMentionText(capture[4]); const content = `${capture[1]}@${threadName}`; return { threadInfo, content, hasAccessToChat: !!threadInfo, }; } const blockQuoteRegex: RegExp = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$)/; const blockQuoteStripFollowingNewlineRegex: RegExp = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$){2}/; const maxNestedQuotations = 5; // Custom match and parse functions implementation for block quotes // to allow us to specify quotes parsing depth // to avoid too many recursive calls and e.g. app crash function matchBlockQuote(quoteRegex: RegExp): MatchFunction { return (source: string, state: State) => { if ( state.inline || (state?.quotationsDepth && state.quotationsDepth >= maxNestedQuotations) ) { return null; } return quoteRegex.exec(source); }; } function parseBlockQuote( capture: Capture, parse: Parser, state: State, ): UnTypedASTNode { const content = capture[1].replace(/^ *> ?/gm, ''); const currentQuotationsDepth = state?.quotationsDepth ?? 0; return { content: parse(content, { ...state, quotationsDepth: currentQuotationsDepth + 1, }), }; } const spoilerRegex: RegExp = /^\|\|([^\n]+?)\|\|/g; const replaceSpoilerRegex: RegExp = /\|\|(.+?)\|\|/g; const spoilerReplacement: string = '⬛⬛⬛'; const stripSpoilersFromNotifications = (text: string): string => text.replace(replaceSpoilerRegex, spoilerReplacement); function stripSpoilersFromMarkdownAST(ast: SingleASTNode[]): SingleASTNode[] { // Either takes top-level AST, or array of nodes under an items node (list) return ast.map(replaceSpoilersFromMarkdownAST); } function replaceSpoilersFromMarkdownAST(node: SingleASTNode): SingleASTNode { const { content, items, type } = node; if (typeof content === 'string') { // Base case (leaf node) return node; } else if (type === 'spoiler') { // The actual point of this function: replacing the spoilers return { type: 'text', content: spoilerReplacement, }; } else if (content) { // Common case... most nodes nest children with content // If content isn't a string, it should be an array return { ...node, content: stripSpoilersFromMarkdownAST(content), }; } else if (items) { // Special case for lists, which has a nested array of arrays within items return { ...node, items: items.map(stripSpoilersFromMarkdownAST), }; } throw new Error( `unexpected Markdown node of type ${type} with no content or items`, ); } const ensRegex: RegExp = /^.{3,}\.eth$/; export { paragraphRegex, paragraphStripTrailingNewlineRegex, urlRegex, blockQuoteRegex, blockQuoteStripFollowingNewlineRegex, headingRegex, headingStripFollowingNewlineRegex, codeBlockRegex, codeBlockStripTrailingNewlineRegex, fenceRegex, fenceStripTrailingNewlineRegex, spoilerRegex, matchBlockQuote, parseBlockQuote, jsonMatch, jsonPrint, matchList, parseList, - createMemberMapForUserMentions, + useMemberMapForUserMentions, matchUserMentions, parseUserMentions, stripSpoilersFromNotifications, stripSpoilersFromMarkdownAST, parseChatMention, ensRegex, }; diff --git a/native/markdown/rules.react.js b/native/markdown/rules.react.js index 7d1a5ab50..6eceec02a 100644 --- a/native/markdown/rules.react.js +++ b/native/markdown/rules.react.js @@ -1,448 +1,453 @@ // @flow import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import { Text, View, Platform } from 'react-native'; import * as SimpleMarkdown from 'simple-markdown'; import * as SharedMarkdown from 'lib/shared/markdown.js'; import { chatMentionRegex } from 'lib/shared/mention-utils.js'; import type { ChatMentionCandidates, - RelativeMemberInfo, ThreadInfo, } from 'lib/types/thread-types.js'; import MarkdownChatMention from './markdown-chat-mention.react.js'; import MarkdownLink from './markdown-link.react.js'; import MarkdownParagraph from './markdown-paragraph.react.js'; import MarkdownSpoiler from './markdown-spoiler.react.js'; import MarkdownUserMention from './markdown-user-mention.react.js'; import { getMarkdownStyles } from './styles.js'; export type MarkdownRules = { +simpleMarkdownRules: SharedMarkdown.ParserRules, +emojiOnlyFactor: ?number, // We need to use a Text container for Entry because it needs to match up // exactly with TextInput. However, if we use a Text container, we can't // support styles for things like blockQuote, which rely on rendering as a // View, and Views can't be nested inside Texts without explicit height and // width +container: 'View' | 'Text', }; // Entry requires a seamless transition between Markdown and TextInput // components, so we can't do anything that would change the position of text const inlineMarkdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const simpleMarkdownRules = { // Matches 'https://google.com' during parse phase and returns a 'link' node url: { ...SimpleMarkdown.defaultRules.url, // simple-markdown is case-sensitive, but we don't want to be match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...SimpleMarkdown.defaultRules.link, match: () => null, react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { return ( {output(node.content, state)} ); }, }, // Each line gets parsed into a 'paragraph' node. The AST returned by the // parser will be an array of one or more 'paragraph' nodes paragraph: { ...SimpleMarkdown.defaultRules.paragraph, // simple-markdown's default RegEx collapses multiple newlines into one. // We want to keep the newlines, but when rendering within a View, we // strip just one trailing newline off, since the View adds vertical // spacing between its children match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } else if (state.container === 'View') { return SharedMarkdown.paragraphStripTrailingNewlineRegex.exec(source); } else { return SharedMarkdown.paragraphRegex.exec(source); } }, parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { let content = capture[1]; if (state.container === 'View') { // React Native renders empty lines with less height. We want to // preserve the newline characters, so we replace empty lines with a // single space content = content.replace(/^$/m, ' '); } return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, // This is the leaf node in the AST returned by the parse phase text: SimpleMarkdown.defaultRules.text, }; return { simpleMarkdownRules, emojiOnlyFactor: null, container: 'Text', }; }); // We allow the most markdown features for TextMessage, which doesn't have the // same requirements as Entry const fullMarkdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const inlineRules = inlineMarkdownRules(useDarkStyle); const simpleMarkdownRules = { ...inlineRules.simpleMarkdownRules, // Matches '' during parse phase and returns a 'link' // node autolink: SimpleMarkdown.defaultRules.autolink, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...inlineRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, mailto: SimpleMarkdown.defaultRules.mailto, em: { ...SimpleMarkdown.defaultRules.em, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, strong: { ...SimpleMarkdown.defaultRules.strong, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, u: { ...SimpleMarkdown.defaultRules.u, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, del: { ...SimpleMarkdown.defaultRules.del, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, spoiler: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: SimpleMarkdown.inlineRegex(SharedMarkdown.spoilerRegex), parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { const content = capture[1]; return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( ), }, inlineCode: { ...SimpleMarkdown.defaultRules.inlineCode, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex( SharedMarkdown.headingStripFollowingNewlineRegex, ), // eslint-disable-next-line react/display-name react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { const headingStyle = styles['h' + node.level]; return ( {output(node.content, state)} ); }, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SharedMarkdown.matchBlockQuote( SharedMarkdown.blockQuoteStripFollowingNewlineRegex, ), parse: SharedMarkdown.parseBlockQuote, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => { const { isNestedQuote } = state; const backgroundColor = isNestedQuote ? '#00000000' : '#00000066'; const borderLeftColor = (Platform.select({ ios: '#00000066', default: isNestedQuote ? '#00000066' : '#000000A3', }): string); return ( {output(node.content, { ...state, isNestedQuote: true })} ); }, }, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex( SharedMarkdown.codeBlockStripTrailingNewlineRegex, ), parse(capture: SharedMarkdown.Capture) { return { content: capture[1].replace(/^ {4}/gm, ''), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex( SharedMarkdown.fenceStripTrailingNewlineRegex, ), parse: (capture: SharedMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SharedMarkdown.Capture) => { const jsonCapture: SharedMarkdown.JSONCapture = (capture: any); return { type: 'codeBlock', content: SharedMarkdown.jsonPrint(jsonCapture), }; }, }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { const children = node.items.map((item, i) => { const content = output(item, state); const bulletValue = node.ordered ? node.start + i + '. ' : '\u2022 '; return ( {bulletValue} {content} ); }); return {children}; }, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...inlineRules, simpleMarkdownRules, emojiOnlyFactor: 2, container: 'View', }; }); function useTextMessageRulesFunc( threadInfo: ThreadInfo, chatMentionCandidates: ChatMentionCandidates, ): (useDarkStyle: boolean) => MarkdownRules { const { members } = threadInfo; + const membersMap = SharedMarkdown.useMemberMapForUserMentions(members); + return React.useMemo( () => _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => - textMessageRules(members, chatMentionCandidates, useDarkStyle), + textMessageRules(chatMentionCandidates, useDarkStyle, membersMap), ), - [members, chatMentionCandidates], + [chatMentionCandidates, membersMap], ); } function textMessageRules( - members: $ReadOnlyArray, chatMentionCandidates: ChatMentionCandidates, useDarkStyle: boolean, + membersMap: $ReadOnlyMap, ): MarkdownRules { const baseRules = fullMarkdownRules(useDarkStyle); - const membersMap = SharedMarkdown.createMemberMapForUserMentions(members); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, userMention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchUserMentions(membersMap), parse: (capture: SharedMarkdown.Capture) => SharedMarkdown.parseUserMentions(membersMap, capture), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, chatMention: { ...SimpleMarkdown.defaultRules.strong, match: SimpleMarkdown.inlineRegex(chatMentionRegex), parse: (capture: SharedMarkdown.Capture) => SharedMarkdown.parseChatMention(chatMentionCandidates, capture), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => node.hasAccessToChat ? ( {node.content} ) : ( {node.content} ), }, }, }; } let defaultTextMessageRules = null; +const defaultMembersMap = new Map(); function getDefaultTextMessageRules( overrideDefaultChatMentionCandidates: ChatMentionCandidates = {}, ): MarkdownRules { if (Object.keys(overrideDefaultChatMentionCandidates).length > 0) { - return textMessageRules([], overrideDefaultChatMentionCandidates, false); + return textMessageRules( + overrideDefaultChatMentionCandidates, + false, + defaultMembersMap, + ); } if (!defaultTextMessageRules) { - defaultTextMessageRules = textMessageRules([], {}, false); + defaultTextMessageRules = textMessageRules({}, false, defaultMembersMap); } return defaultTextMessageRules; } export { inlineMarkdownRules, useTextMessageRulesFunc, getDefaultTextMessageRules, }; diff --git a/web/markdown/rules.react.js b/web/markdown/rules.react.js index 0be7e606c..7a54359a0 100644 --- a/web/markdown/rules.react.js +++ b/web/markdown/rules.react.js @@ -1,248 +1,253 @@ // @flow import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import * as SimpleMarkdown from 'simple-markdown'; import * as SharedMarkdown from 'lib/shared/markdown.js'; import { chatMentionRegex } from 'lib/shared/mention-utils.js'; import type { ChatMentionCandidates, - RelativeMemberInfo, ThreadInfo, } from 'lib/types/thread-types.js'; import MarkdownChatMention from './markdown-chat-mention.react.js'; import MarkdownSpoiler from './markdown-spoiler.react.js'; import MarkdownUserMention from './markdown-user-mention.react.js'; export type MarkdownRules = { +simpleMarkdownRules: SharedMarkdown.ParserRules, +useDarkStyle: boolean, }; const linkRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const simpleMarkdownRules = { // We are using default simple-markdown rules // For more details, look at native/markdown/rules.react link: { ...SimpleMarkdown.defaultRules.link, match: () => null, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, paragraph: { ...SimpleMarkdown.defaultRules.paragraph, match: SimpleMarkdown.blockRegex(SharedMarkdown.paragraphRegex), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, text: SimpleMarkdown.defaultRules.text, url: { ...SimpleMarkdown.defaultRules.url, match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, }; return { simpleMarkdownRules: simpleMarkdownRules, useDarkStyle, }; }); const markdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const linkMarkdownRules = linkRules(useDarkStyle); const simpleMarkdownRules = { ...linkMarkdownRules.simpleMarkdownRules, autolink: SimpleMarkdown.defaultRules.autolink, link: { ...linkMarkdownRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SharedMarkdown.matchBlockQuote(SharedMarkdown.blockQuoteRegex), parse: SharedMarkdown.parseBlockQuote, }, spoiler: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: SimpleMarkdown.inlineRegex(SharedMarkdown.spoilerRegex), parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { const content = capture[1]; return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( ), }, inlineCode: SimpleMarkdown.defaultRules.inlineCode, em: SimpleMarkdown.defaultRules.em, strong: SimpleMarkdown.defaultRules.strong, del: SimpleMarkdown.defaultRules.del, u: SimpleMarkdown.defaultRules.u, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex(SharedMarkdown.headingRegex), }, mailto: SimpleMarkdown.defaultRules.mailto, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex(SharedMarkdown.codeBlockRegex), parse: (capture: SharedMarkdown.Capture) => ({ content: capture[0].replace(/^ {4}/gm, ''), }), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex(SharedMarkdown.fenceRegex), parse: (capture: SharedMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SharedMarkdown.Capture) => { const jsonCapture: SharedMarkdown.JSONCapture = (capture: any); return { type: 'codeBlock', content: SharedMarkdown.jsonPrint(jsonCapture), }; }, }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...linkMarkdownRules, simpleMarkdownRules, useDarkStyle, }; }); function useTextMessageRulesFunc( threadInfo: ThreadInfo, chatMentionCandidates: ChatMentionCandidates, ): boolean => MarkdownRules { const { members } = threadInfo; + const membersMap = SharedMarkdown.useMemberMapForUserMentions(members); + return React.useMemo( () => _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => - textMessageRules(members, chatMentionCandidates, useDarkStyle), + textMessageRules(chatMentionCandidates, useDarkStyle, membersMap), ), - [chatMentionCandidates, members], + [chatMentionCandidates, membersMap], ); } function textMessageRules( - members: $ReadOnlyArray, chatMentionCandidates: ChatMentionCandidates, useDarkStyle: boolean, + membersMap: $ReadOnlyMap, ): MarkdownRules { const baseRules = markdownRules(useDarkStyle); - const membersMap = SharedMarkdown.createMemberMapForUserMentions(members); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, userMention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchUserMentions(membersMap), parse: (capture: SharedMarkdown.Capture) => SharedMarkdown.parseUserMentions(membersMap, capture), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( ), }, chatMention: { ...SimpleMarkdown.defaultRules.strong, match: SimpleMarkdown.inlineRegex(chatMentionRegex), parse: (capture: SharedMarkdown.Capture) => SharedMarkdown.parseChatMention(chatMentionCandidates, capture), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( ), }, }, }; } let defaultTextMessageRules = null; +const defaultMembersMap = new Map(); function getDefaultTextMessageRules( overrideDefaultChatMentionCandidates: ChatMentionCandidates = {}, ): MarkdownRules { if (Object.keys(overrideDefaultChatMentionCandidates).length > 0) { - return textMessageRules([], overrideDefaultChatMentionCandidates, false); + return textMessageRules( + overrideDefaultChatMentionCandidates, + false, + defaultMembersMap, + ); } if (!defaultTextMessageRules) { - defaultTextMessageRules = textMessageRules([], {}, false); + defaultTextMessageRules = textMessageRules({}, false, defaultMembersMap); } return defaultTextMessageRules; } export { linkRules, useTextMessageRulesFunc, getDefaultTextMessageRules };