// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. /* eslint-disable max-lines */ import {useManagedConfig} from '@mattermost/react-native-emm'; import {Parser, Node} from 'commonmark'; import Renderer from 'commonmark-react-renderer'; import React, {type ReactElement, useCallback, useMemo, useRef} from 'react'; import {Dimensions, type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle} from 'react-native'; import CompassIcon from '@components/compass_icon'; import EditedIndicator from '@components/edited_indicator'; import Emoji from '@components/emoji'; import FormattedText from '@components/formatted_text'; import {useServerUrl} from '@context/server'; import {logError} from '@utils/log'; import {computeTextStyle, getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import AtMention from './at_mention'; import ChannelMention from './channel_mention'; import Hashtag from './hashtag'; import InlineEntityLink, {type InlineEntityType} from './inline_entity_link'; import MarkdownBlockQuote from './markdown_block_quote'; import MarkdownCodeBlock from './markdown_code_block'; import MarkdownImage from './markdown_image'; import MarkdownLatexCodeBlock from './markdown_latex_block'; import MarkdownLatexInline from './markdown_latex_inline'; import MarkdownLink from './markdown_link'; import MarkdownList from './markdown_list'; import MarkdownListItem from './markdown_list_item'; import MarkdownTable from './markdown_table'; import MarkdownTableCell, {type MarkdownTableCellProps} from './markdown_table_cell'; import MarkdownTableImage from './markdown_table_image'; import MarkdownTableRow, {type MarkdownTableRowProps} from './markdown_table_row'; import {addListItemIndices, combineTextNodes, highlightMentions, highlightWithoutNotification, highlightSearchPatterns, parseTaskLists, processInlineEntities, pullOutImages} from './transform'; import type {ChannelMentions} from './channel_mention/channel_mention'; import type { MarkdownAtMentionRenderer, MarkdownBaseRenderer, MarkdownBlockStyles, MarkdownChannelMentionRenderer, MarkdownEmojiRenderer, MarkdownImageRenderer, MarkdownLatexRenderer, MarkdownTextStyles, SearchPattern, UserMentionKey, HighlightWithoutNotificationKey, } from '@typings/global/markdown'; import type {AvailableScreens} from '@typings/screens/navigation'; type MarkdownProps = { baseTextStyle: StyleProp; baseParagraphStyle?: StyleProp; channelId?: string; channelMentions?: ChannelMentions; disableAtChannelMentionHighlight?: boolean; disableAtMentions?: boolean; disableBlockQuote?: boolean; disableChannelLink?: boolean; disableCodeBlock?: boolean; disableGallery?: boolean; disableHashtags?: boolean; disableHeading?: boolean; disableQuotes?: boolean; disableTables?: boolean; enableLatex: boolean; enableInlineLatex: boolean; highlightKeys?: HighlightWithoutNotificationKey[]; imagesMetadata?: Record; isEdited?: boolean; isReplyPost?: boolean; isSearchResult?: boolean; layoutHeight?: number; layoutWidth?: number; location: AvailableScreens; maxNodes: number; mentionKeys?: UserMentionKey[]; minimumHashtagLength?: number; postId?: string; searchPatterns?: SearchPattern[]; theme: Theme; value?: string; onLinkLongPress?: (url?: string) => void; isUnsafeLinksPost?: boolean; } const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { block: { alignItems: 'flex-start', flexDirection: 'row', flexWrap: 'wrap', }, errorMessage: { color: theme.errorTextColor, ...typography('Body', 100), }, maxNodesWarning: { color: theme.errorTextColor, }, atMentionOpacity: { opacity: 1, }, bold: { fontWeight: '600', }, }; }); const getExtraPropsForNode = (node: any) => { const extraProps: Record = { continue: node.continue, index: node.index, }; if (node.type === 'image') { extraProps.reactChildren = node.react.children; extraProps.linkDestination = node.linkDestination; extraProps.size = node.size; } if (node.type === 'checkbox') { extraProps.isChecked = node.isChecked; } if (node.type === 'inline_entity_link') { extraProps.entityType = node.entityType; extraProps.entityId = node.entityId; extraProps.linkUrl = node.linkUrl; } return extraProps; }; const renderHashtagWithStyles = ( context: string[], hashtag: string, textStyles: MarkdownTextStyles, baseTextStyle: StyleProp, ) => { const computedStyle = computeTextStyle(textStyles, baseTextStyle, context); const linkStyle = [computedStyle, textStyles.link]; const headingIndex = context.findIndex((c) => c.includes('heading')); if (headingIndex > -1) { linkStyle.push(textStyles[context[headingIndex]]); } return ( ); }; const Markdown = ({ baseTextStyle, channelId, channelMentions, disableAtChannelMentionHighlight, disableAtMentions, disableBlockQuote, disableChannelLink, disableCodeBlock, disableGallery, disableHashtags, disableHeading, disableTables, enableInlineLatex, enableLatex, maxNodes, imagesMetadata, isEdited, isReplyPost, isSearchResult, layoutHeight, layoutWidth, location, mentionKeys, highlightKeys, minimumHashtagLength = 3, postId, searchPatterns, theme, value = '', baseParagraphStyle, onLinkLongPress, isUnsafeLinksPost, }: MarkdownProps) => { const style = getStyleSheet(theme); const blockStyles = useMemo(() => getMarkdownBlockStyles(theme), [theme]); const textStyles = useMemo(() => getMarkdownTextStyles(theme), [theme]); const managedConfig = useManagedConfig(); const serverUrl = useServerUrl(); const renderText = useCallback(({context, literal}: MarkdownBaseRenderer) => { const selectable = (managedConfig.copyAndPasteProtection !== 'true') && context.includes('table_cell'); if (context.indexOf('image') !== -1) { // If this text is displayed, it will be styled by the image component return ( {literal} ); } // Construct the text style based off of the parents of this node since RN's inheritance is limited let styles: StyleProp; if (disableHeading) { styles = computeTextStyle(textStyles, baseTextStyle, context.filter((c) => !c.startsWith('heading'))); } else { styles = computeTextStyle(textStyles, baseTextStyle, context); } if (context.includes('mention_highlight')) { styles = [styles, {backgroundColor: theme.mentionHighlightBg}]; } return ( {literal} ); }, [baseTextStyle, disableHeading, managedConfig.copyAndPasteProtection, textStyles, theme.mentionHighlightBg]); const renderAtMention = useCallback(({context, mentionName}: MarkdownAtMentionRenderer) => { if (disableAtMentions) { return renderText({context, literal: `@${mentionName}`}); } const computedStyles = StyleSheet.flatten(computeTextStyle(textStyles, baseTextStyle, context)); const {fontFamily, fontSize, fontWeight} = computedStyles; return ( ); }, [baseTextStyle, channelId, disableAtChannelMentionHighlight, disableAtMentions, isSearchResult, location, mentionKeys, renderText, style.atMentionOpacity, textStyles, theme]); const renderBlockQuote = useCallback(({children, ...otherProps}: any) => { if (disableBlockQuote) { return null; } return ( {children} ); }, [disableBlockQuote, blockStyles?.quoteBlockIcon]); const renderBreak = () => { return {'\n'}; }; const renderChannelLink = useCallback(({context, channelName}: MarkdownChannelMentionRenderer) => { if (disableChannelLink || isUnsafeLinksPost) { return renderText({context, literal: `~${channelName}`}); } return ( ); }, [baseTextStyle, channelMentions, disableChannelLink, isUnsafeLinksPost, renderText, textStyles]); const renderCheckbox = useCallback(({isChecked}: {isChecked: boolean}) => { return ( {' '} ); }, [theme.centerChannelColor]); const renderInlineEntityLink = useCallback(({entityType, entityId, linkUrl}: {entityType: InlineEntityType; entityId: string; linkUrl?: string}) => { if (!entityType || !entityId) { return null; } return ( ); }, []); const renderCodeBlock = useCallback((props: any) => { if (disableCodeBlock) { return null; } // These sometimes include a trailing newline const content = props.literal.replace(/\n$/, ''); if (enableLatex && !isUnsafeLinksPost && props.language === 'latex') { return ( ); } return ( ); }, [disableCodeBlock, enableLatex, isUnsafeLinksPost, textStyles.codeBlock, theme]); const renderCodeSpan = useCallback(({context, literal}: MarkdownBaseRenderer) => { const {code} = textStyles; return ( {literal} ); }, [baseTextStyle, textStyles]); const renderEditedIndicator = useCallback(({context}: {context: string[]}) => { return ( ); }, [baseTextStyle, theme]); const renderEmoji = useCallback(({context, emojiName, literal}: MarkdownEmojiRenderer) => { return ( ); }, [baseTextStyle, textStyles]); const renderHashtag = useCallback(({context, hashtag}: {context: string[]; hashtag: string}) => { if (disableHashtags || isUnsafeLinksPost) { return renderText({context, literal: `#${hashtag}`}); } return renderHashtagWithStyles(context, hashtag, textStyles, baseTextStyle); }, [baseTextStyle, disableHashtags, isUnsafeLinksPost, renderText, textStyles]); const renderHeading = useCallback(({children, level}: {children: ReactElement; level: string}) => { if (disableHeading) { return ( {children} ); } const containerStyle = [ style.block, textStyles[`heading${level}`], ]; const textStyle = textStyles[`heading${level}Text`]; return ( } testID='markdown_heading' > {children} ); }, [disableHeading, style.block, style.bold, textStyles]); const renderHtml = useCallback((props: any) => { let rendered = renderText(props); if (props.isBlock) { rendered = ( {rendered} ); } return rendered; }, [renderText, style.block]); const renderImage = useCallback(({linkDestination, context, src, size}: MarkdownImageRenderer) => { if (!imagesMetadata || isUnsafeLinksPost) { return null; } const isInsideLink = context.indexOf('link') !== -1; const disableInteraction = (disableGallery ?? Boolean(!location)) || isInsideLink; if (context.indexOf('table') !== -1) { // We have enough problems rendering images as is, so just render a link inside of a table return ( ); } return ( ); }, [baseTextStyle, disableGallery, imagesMetadata, isReplyPost, isUnsafeLinksPost, layoutHeight, layoutWidth, location, postId, textStyles, theme]); const renderLatexInline = useCallback(({context, latexCode}: MarkdownLatexRenderer) => { if (!enableInlineLatex || isUnsafeLinksPost) { return renderText({context, literal: `$${latexCode}$`}); } return ( ); }, [enableInlineLatex, isUnsafeLinksPost, renderText, theme]); const renderLink = useCallback(({children, href}: {children: ReactElement; href: string}) => { if (isUnsafeLinksPost) { return renderText({context: [], literal: href}); } return ( {children} ); }, [isUnsafeLinksPost, onLinkLongPress, renderText, theme]); const renderList = useCallback(({children, start, tight, type}: any) => { return ( {children} ); }, []); const renderListItem = useCallback(({children, context, ...otherProps}: any) => { const level = context.filter((type: string) => type === 'list').length; return ( {children} ); }, [baseTextStyle]); const renderParagraph = useCallback(({children, first}: {children: ReactElement[]; first: boolean}) => { if (!children || children.length === 0) { return null; } const blockStyle: StyleProp = [style.block]; if (!first) { blockStyle.push(blockStyles?.adjacentParagraph); } return ( {children} ); }, [baseParagraphStyle, blockStyles?.adjacentParagraph, style.block]); const renderTable = useCallback(({children, numColumns}: {children: ReactElement; numColumns: number}) => { if (disableTables) { return null; } return ( {children} ); }, [disableTables, theme]); const renderTableCell = useCallback((args: MarkdownTableCellProps) => { return ( ); }, [theme]); const renderTableRow = useCallback((args: MarkdownTableRowProps) => { return ( ); }, [theme]); const renderThematicBreak = useCallback(() => { return ( ); }, [blockStyles?.horizontalRule]); const renderMaxNodesWarning = useCallback(() => { const styles = [baseTextStyle, style.maxNodesWarning]; return ( ); }, [baseTextStyle, style.maxNodesWarning]); const createRenderer = () => { const renderers: any = { text: renderText, emph: Renderer.forwardChildren, strong: Renderer.forwardChildren, del: Renderer.forwardChildren, code: renderCodeSpan, link: renderLink, image: renderImage, atMention: renderAtMention, channelLink: renderChannelLink, emoji: renderEmoji, hashtag: renderHashtag, latexInline: renderLatexInline, paragraph: renderParagraph, heading: renderHeading, codeBlock: renderCodeBlock, blockQuote: renderBlockQuote, list: renderList, item: renderListItem, hardBreak: renderBreak, thematicBreak: renderThematicBreak, softBreak: renderBreak, htmlBlock: renderHtml, htmlInline: renderHtml, table: renderTable, table_row: renderTableRow, table_cell: renderTableCell, mention_highlight: Renderer.forwardChildren, search_highlight: Renderer.forwardChildren, highlight_without_notification: Renderer.forwardChildren, checkbox: renderCheckbox, inline_entity_link: renderInlineEntityLink, editedIndicator: renderEditedIndicator, maxNodesWarning: renderMaxNodesWarning, }; return new Renderer({ renderers, renderParagraphsInLists: true, maxNodes, getExtraPropsForNode, allowedTypes: Object.keys(renderers), }); }; // Pattern suggested in https://react.dev/reference/react/useRef#avoiding-recreating-the-ref-contents const parserRef = useRef(null); if (parserRef.current === null) { parserRef.current = new Parser({minimumHashtagLength}); } const parser = parserRef.current; const renderer = useMemo(createRenderer, [ renderText, renderCodeSpan, renderLink, renderImage, renderAtMention, renderChannelLink, renderEmoji, renderHashtag, renderLatexInline, renderParagraph, renderHeading, renderCodeBlock, renderBlockQuote, renderList, renderListItem, renderThematicBreak, renderHtml, renderTable, renderTableRow, renderTableCell, renderCheckbox, renderInlineEntityLink, renderEditedIndicator, renderMaxNodesWarning, maxNodes, ]); const errorLogged = useRef(false); const output = useMemo(() => { let ast; try { ast = parser.parse(value.toString()); ast = combineTextNodes(ast); ast = addListItemIndices(ast); ast = pullOutImages(ast); ast = parseTaskLists(ast); ast = processInlineEntities(ast, serverUrl); if (mentionKeys) { ast = highlightMentions(ast, mentionKeys); } if (highlightKeys) { ast = highlightWithoutNotification(ast, highlightKeys); } if (searchPatterns) { ast = highlightSearchPatterns(ast, searchPatterns); } if (isEdited) { const editIndicatorNode = new Node('edited_indicator'); if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) { ast.lastChild.appendChild(editIndicatorNode); } else { const node = new Node('paragraph'); node.appendChild(editIndicatorNode); ast.appendChild(node); } } } catch (e) { if (!errorLogged.current) { logError('An error occurred while parsing Markdown', e); errorLogged.current = true; } return ( ); } try { const generatedOutput = renderer.render(ast); return generatedOutput; } catch (e) { if (!errorLogged.current) { logError('An error occurred while rendering Markdown', e); errorLogged.current = true; } return ( ); } }, [highlightKeys, isEdited, mentionKeys, parser, renderer, searchPatterns, serverUrl, style.errorMessage, value]); return output; }; export const testExports = { renderHashtagWithStyles, }; export default Markdown;