// 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} from 'react'; import PropTypes from 'prop-types'; import { Platform, Text, View, } from 'react-native'; import AtMention from 'app/components/at_mention'; import ChannelLink from 'app/components/channel_link'; import Emoji from 'app/components/emoji'; import FormattedText from 'app/components/formatted_text'; import Hashtag from 'app/components/markdown/hashtag'; import CustomPropTypes from 'app/constants/custom_prop_types'; import {blendColors, concatStyles, makeStyleSheetFromTheme} from 'app/utils/theme'; import {getScheme} from 'app/utils/url'; 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 MarkdownTableImage from './markdown_table_image'; import MarkdownTableRow from './markdown_table_row'; import MarkdownTableCell from './markdown_table_cell'; import { addListItemIndices, combineTextNodes, highlightMentions, pullOutImages, } from './transform'; export default class Markdown extends PureComponent { static propTypes = { autolinkedUrlSchemes: PropTypes.array.isRequired, baseTextStyle: CustomPropTypes.Style, blockStyles: PropTypes.object, channelMentions: PropTypes.object, imagesMetadata: PropTypes.object, isEdited: PropTypes.bool, isReplyPost: PropTypes.bool, isSearchResult: PropTypes.bool, mentionKeys: PropTypes.array.isRequired, minimumHashtagLength: PropTypes.number.isRequired, onChannelLinkPress: PropTypes.func, onHashtagPress: PropTypes.func, onPermalinkPress: PropTypes.func, onPostPress: PropTypes.func, textStyles: PropTypes.object, theme: PropTypes.object.isRequired, value: PropTypes.string.isRequired, disableHashtags: PropTypes.bool, disableAtMentions: PropTypes.bool, disableChannelLink: PropTypes.bool, disableAtChannelMentionHighlight: PropTypes.bool, }; static defaultProps = { textStyles: {}, blockStyles: {}, onLongPress: () => true, disableHashtags: false, disableAtMentions: false, disableChannelLink: false, disableAtChannelMentionHighlight: false, }; constructor(props) { super(props); this.parser = this.createParser(); this.renderer = this.createRenderer(); } createParser = () => { return new Parser({ urlFilter: this.urlFilter, minimumHashtagLength: this.props.minimumHashtagLength, }); }; urlFilter = (url) => { const scheme = getScheme(url); return !scheme || this.props.autolinkedUrlSchemes.indexOf(scheme) !== -1; }; getMentionKeys = () => { const mentionKeys = this.props.mentionKeys; if (this.props.disableAtChannelMentionHighlight) { return mentionKeys.filter((mention) => !['@all', '@channel', '@here'].includes(mention.key)); } return mentionKeys; } createRenderer = () => { return new Renderer({ renderers: { 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, 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, }, renderParagraphsInLists: true, getExtraPropsForNode: this.getExtraPropsForNode, }); }; getExtraPropsForNode = (node) => { const extraProps = { continue: node.continue, index: node.index, }; if (node.type === 'image') { extraProps.reactChildren = node.react.children; extraProps.linkDestination = node.linkDestination; } return extraProps; }; computeTextStyle = (baseStyle, context) => { return concatStyles(baseStyle, context.map((type) => this.props.textStyles[type])); }; renderText = ({context, literal}) => { 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}) => { return {literal}; }; renderImage = ({linkDestination, reactChildren, context, src}) => { if (context.indexOf('table') !== -1) { // We have enough problems rendering images as is, so just render a link inside of a table return ( {reactChildren} ); } return ( {reactChildren} ); }; renderAtMention = ({context, mentionName}) => { if (this.props.disableAtMentions) { return this.renderText({context, literal: `@${mentionName}`}); } const style = getStyleSheet(this.props.theme); return ( ); }; renderChannelLink = ({context, channelName}) => { if (this.props.disableChannelLink) { return this.renderText({context, literal: `~${channelName}`}); } return ( ); }; renderEmoji = ({context, emojiName, literal}) => { return ( ); }; renderHashtag = ({context, hashtag}) => { if (this.props.disableHashtags) { return this.renderText({context, literal: `#${hashtag}`}); } return ( ); }; renderParagraph = ({children, first}) => { 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}) => { const containerStyle = [ getStyleSheet(this.props.theme).block, this.props.blockStyles[`heading${level}`], ]; const textStyle = this.props.blockStyles[`heading${level}Text`]; return ( {children} ); }; renderCodeBlock = (props) => { // These sometimes include a trailing newline const content = props.literal.replace(/\n$/, ''); return ( ); }; renderBlockQuote = ({children, ...otherProps}) => { return ( {children} ); }; renderList = ({children, start, tight, type}) => { return ( {children} ); }; renderListItem = ({children, context, ...otherProps}) => { const level = context.filter((type) => type === 'list').length; return ( {children} ); }; renderHardBreak = () => { return {'\n'}; }; renderThematicBreak = () => { return ; }; renderSoftBreak = () => { return {'\n'}; }; renderHtml = (props) => { let rendered = this.renderText(props); if (props.isBlock) { const style = getStyleSheet(this.props.theme); rendered = ( {rendered} ); } return rendered; }; renderTable = ({children, numColumns}) => { return ( {children} ); }; renderTableRow = (args) => { return ; }; renderTableCell = (args) => { return ; }; renderLink = ({children, href}) => { return ( {children} ); }; renderEditedIndicator = ({context}) => { 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); ast = combineTextNodes(ast); ast = addListItemIndices(ast); ast = pullOutImages(ast); ast = highlightMentions(ast, this.getMentionKeys()); if (this.props.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); } } return this.renderer.render(ast); } } 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, }, }; });