// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import {Parser, Node} from 'commonmark'; import Renderer from 'commonmark-react-renderer'; import React, {PureComponent, ReactElement} from 'react'; import {GestureResponderEvent, Platform, StyleProp, Text, TextStyle, View} from 'react-native'; import Emoji from '@components/emoji'; import FormattedText from '@components/formatted_text'; import Hashtag from '@components/markdown/hashtag'; import {blendColors, concatStyles, makeStyleSheetFromTheme} from '@utils/theme'; import {getScheme} from '@utils/url'; import AtMention from './at_mention'; import ChannelMention, {ChannelMentions} from './channel_mention'; import MarkdownBlockQuote from './markdown_block_quote'; import MarkdownCodeBlock from './markdown_code_block'; import MarkdownImage from './markdown_image'; import MarkdownLink from './markdown_link'; import MarkdownList from './markdown_list'; import MarkdownListItem from './markdown_list_item'; import MarkdownTable from './markdown_table'; import MarkdownTableCell, {MarkdownTableCellProps} from './markdown_table_cell'; import MarkdownTableImage from './markdown_table_image'; import MarkdownTableRow, {MarkdownTableRowProps} from './markdown_table_row'; import {addListItemIndices, combineTextNodes, highlightMentions, pullOutImages} from './transform'; import type { MarkdownAtMentionRenderer, MarkdownBaseRenderer, MarkdownBlockStyles, MarkdownChannelMentionRenderer, MarkdownEmojiRenderer, MarkdownImageRenderer, MarkdownTextStyles, UserMentionKey, } from '@typings/global/markdown'; type MarkdownProps = { autolinkedUrlSchemes?: string[]; baseTextStyle: StyleProp; blockStyles: MarkdownBlockStyles; channelMentions?: ChannelMentions; disableAtMentions?: boolean; disableAtChannelMentionHighlight?: boolean; disableChannelLink?: boolean; disableGallery?: boolean; disableHashtags?: boolean; imagesMetadata?: Record; isEdited?: boolean; isReplyPost?: boolean; isSearchResult?: boolean; layoutWidth?: number; location?: string; mentionKeys?: UserMentionKey[]; minimumHashtagLength?: number; onPostPress?: (event: GestureResponderEvent) => void; postId?: string; textStyles: MarkdownTextStyles; theme: Theme; value: string | number; } 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, }, atMentionOpacity: { opacity: 1, }, }; }); class Markdown extends PureComponent { static defaultProps = { textStyles: {}, blockStyles: {}, disableHashtags: false, disableAtMentions: false, disableAtChannelMentionHighlight: false, disableChannelLink: false, disableGallery: false, layoutWidth: undefined, value: '', minimumHashtagLength: 3, }; private parser: Parser; private renderer: Renderer.Renderer; constructor(props: MarkdownProps) { super(props); this.parser = this.createParser(); this.renderer = this.createRenderer(); } createParser = () => { return new Parser({ urlFilter: this.urlFilter, minimumHashtagLength: this.props.minimumHashtagLength, }); }; urlFilter = (url: string) => { const scheme = getScheme(url); return !scheme || this.props.autolinkedUrlSchemes?.indexOf(scheme) !== -1; }; createRenderer = () => { const renderers: any = { text: this.renderText, emph: Renderer.forwardChildren, strong: Renderer.forwardChildren, del: Renderer.forwardChildren, code: this.renderCodeSpan, link: this.renderLink, image: this.renderImage, atMention: this.renderAtMention, channelLink: this.renderChannelLink, emoji: this.renderEmoji, hashtag: this.renderHashtag, latexinline: this.renderParagraph, paragraph: this.renderParagraph, heading: this.renderHeading, codeBlock: this.renderCodeBlock, blockQuote: this.renderBlockQuote, list: this.renderList, item: this.renderListItem, hardBreak: this.renderHardBreak, thematicBreak: this.renderThematicBreak, softBreak: this.renderSoftBreak, htmlBlock: this.renderHtml, htmlInline: this.renderHtml, table: this.renderTable, table_row: this.renderTableRow, table_cell: this.renderTableCell, mention_highlight: Renderer.forwardChildren, editedIndicator: this.renderEditedIndicator, }; return new Renderer({ renderers, renderParagraphsInLists: true, getExtraPropsForNode: this.getExtraPropsForNode, }); }; 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; } return extraProps; }; computeTextStyle = (baseStyle: StyleProp, context: any) => { const {textStyles} = this.props; type TextType = keyof typeof textStyles; const contextStyles: TextStyle[] = context.map((type: any) => textStyles[type as TextType]).filter((f: any) => f !== undefined); return contextStyles.length ? concatStyles(baseStyle, contextStyles) : baseStyle; }; renderText = ({context, literal}: MarkdownBaseRenderer) => { 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 const style = this.computeTextStyle(this.props.baseTextStyle, context); return ( {literal} ); }; renderCodeSpan = ({context, literal}: MarkdownBaseRenderer) => { const {baseTextStyle, textStyles: {code}} = this.props; return {literal}; }; renderImage = ({linkDestination, context, src, size}: MarkdownImageRenderer) => { if (!this.props.imagesMetadata) { return null; } if (context.indexOf('table') !== -1) { // We have enough problems rendering images as is, so just render a link inside of a table return ( ); } return ( ); }; renderAtMention = ({context, mentionName}: MarkdownAtMentionRenderer) => { if (this.props.disableAtMentions) { return this.renderText({context, literal: `@${mentionName}`}); } const style = getStyleSheet(this.props.theme); return ( ); }; renderChannelLink = ({context, channelName}: MarkdownChannelMentionRenderer) => { if (this.props.disableChannelLink) { return this.renderText({context, literal: `~${channelName}`}); } return ( ); }; renderEmoji = ({context, emojiName, literal}: MarkdownEmojiRenderer) => { return ( ); }; renderHashtag = ({context, hashtag}: {context: string[]; hashtag: string}) => { if (this.props.disableHashtags) { return this.renderText({context, literal: `#${hashtag}`}); } return ( ); }; renderParagraph = ({children, first}: {children: ReactElement[]; first: boolean}) => { if (!children || children.length === 0) { return null; } const style = getStyleSheet(this.props.theme); const blockStyle = [style.block]; if (!first) { blockStyle.push(this.props.blockStyles.adjacentParagraph); } return ( {children} ); }; renderHeading = ({children, level}: {children: ReactElement; level: string}) => { const {textStyles} = this.props; const containerStyle = [ getStyleSheet(this.props.theme).block, textStyles[`heading${level}`], ]; const textStyle = textStyles[`heading${level}Text`]; return ( {children} ); }; renderCodeBlock = (props: any) => { // These sometimes include a trailing newline const content = props.literal.replace(/\n$/, ''); return ( ); }; renderBlockQuote = ({children, ...otherProps}: any) => { return ( {children} ); }; renderList = ({children, start, tight, type}: any) => { return ( {children} ); }; renderListItem = ({children, context, ...otherProps}: any) => { const level = context.filter((type: string) => type === 'list').length; return ( {children} ); }; renderHardBreak = () => { return {'\n'}; }; renderThematicBreak = () => { return ( ); }; renderSoftBreak = () => { return {'\n'}; }; renderHtml = (props: any) => { let rendered = this.renderText(props); if (props.isBlock) { const style = getStyleSheet(this.props.theme); rendered = ( {rendered} ); } return rendered; }; renderTable = ({children, numColumns}: {children: ReactElement; numColumns: number}) => { return ( {children} ); }; renderTableRow = (args: MarkdownTableRowProps) => { return ; }; renderTableCell = (args: MarkdownTableCellProps) => { return ; }; renderLink = ({children, href}: {children: ReactElement; href: string}) => { return ( {children} ); }; renderEditedIndicator = ({context}: {context: string[]}) => { let spacer = ''; if (context[0] === 'paragraph') { spacer = ' '; } const style = getStyleSheet(this.props.theme); const styles = [ this.props.baseTextStyle, style.editedIndicatorText, ]; return ( {spacer} ); }; render() { let ast = this.parser.parse(this.props.value.toString()); ast = combineTextNodes(ast); ast = addListItemIndices(ast); ast = pullOutImages(ast); if (this.props.mentionKeys) { ast = highlightMentions(ast, this.props.mentionKeys); } if (this.props.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); } } return this.renderer.render(ast); } } export default Markdown;