// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {useManagedConfig} from '@mattermost/react-native-emm'; import {Parser, Node} from 'commonmark'; import Renderer from 'commonmark-react-renderer'; import React, {type ReactElement, useMemo, useRef} from 'react'; import {Dimensions, type GestureResponderEvent, Platform, type StyleProp, StyleSheet, Text, type TextStyle, View, type ViewStyle} from 'react-native'; import CompassIcon from '@components/compass_icon'; import Emoji from '@components/emoji'; import FormattedText from '@components/formatted_text'; import {logError} from '@utils/log'; import {computeTextStyle} from '@utils/markdown'; import {blendColors, changeOpacity, concatStyles, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import {getScheme} from '@utils/url'; import AtMention from './at_mention'; import ChannelMention from './channel_mention'; import Hashtag from './hashtag'; 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, 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'; type MarkdownProps = { autolinkedUrlSchemes?: string[]; baseTextStyle: StyleProp; baseParagraphStyle?: StyleProp; blockStyles?: MarkdownBlockStyles; 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: string; maxNodes: number; mentionKeys?: UserMentionKey[]; minimumHashtagLength?: number; onPostPress?: (event: GestureResponderEvent) => void; postId?: string; searchPatterns?: SearchPattern[]; textStyles?: MarkdownTextStyles; theme: Theme; value?: string; onLinkLongPress?: (url?: string) => void; isUnsafeLinksPost?: boolean; } const getStyleSheet = makeStyleSheetFromTheme((theme) => { // Android has trouble giving text transparency depending on how it's nested, // so we calculate the resulting colour manually const editedOpacity = Platform.select({ ios: 0.3, android: 1.0, }); const editedColor = Platform.select({ ios: theme.centerChannelColor, android: blendColors(theme.centerChannelBg, theme.centerChannelColor, 0.3), }); return { block: { alignItems: 'flex-start', flexDirection: 'row', flexWrap: 'wrap', }, editedIndicatorText: { color: editedColor, opacity: editedOpacity, }, 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; } return extraProps; }; const Markdown = ({ autolinkedUrlSchemes, baseTextStyle, blockStyles, 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, onPostPress, postId, searchPatterns, textStyles = {}, theme, value = '', baseParagraphStyle, onLinkLongPress, isUnsafeLinksPost, }: MarkdownProps) => { const style = getStyleSheet(theme); const managedConfig = useManagedConfig(); const urlFilter = (url: string) => { const scheme = getScheme(url); return !scheme || autolinkedUrlSchemes?.indexOf(scheme) !== -1; }; const renderAtMention = ({context, mentionName}: MarkdownAtMentionRenderer) => { if (disableAtMentions) { return renderText({context, literal: `@${mentionName}`}); } const computedStyles = StyleSheet.flatten(computeTextStyle(textStyles, baseTextStyle, context)); const {fontFamily, fontSize, fontWeight} = computedStyles; return ( ); }; const renderBlockQuote = ({children, ...otherProps}: any) => { if (disableBlockQuote) { return null; } return ( {children} ); }; const renderBreak = () => { return {'\n'}; }; const renderChannelLink = ({context, channelName}: MarkdownChannelMentionRenderer) => { if (disableChannelLink || isUnsafeLinksPost) { return renderText({context, literal: `~${channelName}`}); } return ( ); }; const renderCheckbox = ({isChecked}: {isChecked: boolean}) => { return ( {' '} ); }; const renderCodeBlock = (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 ( ); }; const renderCodeSpan = ({context, literal}: MarkdownBaseRenderer) => { const {code} = textStyles; return ( {literal} ); }; const renderEditedIndicator = ({context}: {context: string[]}) => { let spacer = ''; const styles = [baseTextStyle, style.editedIndicatorText]; if (context[0] === 'paragraph') { spacer = ' '; } return ( {spacer} ); }; const renderEmoji = ({context, emojiName, literal}: MarkdownEmojiRenderer) => { return ( ); }; const renderHashtag = ({context, hashtag}: {context: string[]; hashtag: string}) => { if (disableHashtags || isUnsafeLinksPost) { return renderText({context, literal: `#${hashtag}`}); } const linkStyle = [textStyles.link]; const headingIndex = context.findIndex((c) => c.includes('heading')); if (headingIndex > -1) { linkStyle.push(textStyles[context[headingIndex]]); } return ( ); }; const renderHeading = ({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} ); }; const renderHtml = (props: any) => { let rendered = renderText(props); if (props.isBlock) { rendered = ( {rendered} ); } return rendered; }; const renderImage = ({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 ( ); }; const renderLatexInline = ({context, latexCode}: MarkdownLatexRenderer) => { if (!enableInlineLatex || isUnsafeLinksPost) { return renderText({context, literal: `$${latexCode}$`}); } return ( ); }; const renderLink = ({children, href}: {children: ReactElement; href: string}) => { if (isUnsafeLinksPost) { return renderText({context: [], literal: href}); } return ( {children} ); }; const renderList = ({children, start, tight, type}: any) => { return ( {children} ); }; const renderListItem = ({children, context, ...otherProps}: any) => { const level = context.filter((type: string) => type === 'list').length; return ( {children} ); }; const renderParagraph = ({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} ); }; const renderTable = ({children, numColumns}: {children: ReactElement; numColumns: number}) => { if (disableTables) { return null; } return ( {children} ); }; const renderTableCell = (args: MarkdownTableCellProps) => { return ; }; const renderTableRow = (args: MarkdownTableRowProps) => { return ; }; const renderText = ({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; if (disableHeading) { styles = computeTextStyle(textStyles, baseTextStyle, context.filter((c) => !c.startsWith('heading'))); } else { styles = computeTextStyle(textStyles, baseTextStyle, context); } if (context.includes('mention_highlight')) { styles = concatStyles(styles, {backgroundColor: theme.mentionHighlightBg}); } return ( {literal} ); }; const renderThematicBreak = () => { return ( ); }; const renderMaxNodesWarning = () => { const styles = [baseTextStyle, style.maxNodesWarning]; return ( ); }; 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, editedIndicator: renderEditedIndicator, maxNodesWarning: renderMaxNodesWarning, }; return new Renderer({ renderers, renderParagraphsInLists: true, maxNodes, getExtraPropsForNode, allowedTypes: Object.keys(renderers), }); }; const parser = useRef(new Parser({urlFilter, minimumHashtagLength})).current; const renderer = useMemo(createRenderer, [theme, textStyles]); const errorLogged = useRef(false); let ast; try { ast = parser.parse(value.toString()); ast = combineTextNodes(ast); ast = addListItemIndices(ast); ast = pullOutImages(ast); ast = parseTaskLists(ast); 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.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 ( ); } let output; try { output = renderer.render(ast); } catch (e) { if (!errorLogged.current) { logError('An error occurred while rendering Markdown', e); errorLogged.current = true; } return ( ); } return output; }; export default Markdown;