// Copyright (c) 2017-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 CustomPropTypes from 'app/constants/custom_prop_types'; import {blendColors, concatStyles, makeStyleSheetFromTheme} from 'app/utils/theme'; 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 {addListItemIndices, pullOutImages} from './transform'; export default class Markdown extends PureComponent { static propTypes = { baseTextStyle: CustomPropTypes.Style, blockStyles: PropTypes.object, emojiSizes: PropTypes.object, fontSizes: PropTypes.object, isEdited: PropTypes.bool, isSearchResult: PropTypes.bool, navigator: PropTypes.object.isRequired, onLongPress: PropTypes.func, onPostPress: PropTypes.func, textStyles: PropTypes.object, theme: PropTypes.object.isRequired, value: PropTypes.string.isRequired }; static defaultProps = { textStyles: {}, blockStyles: {}, emojiSizes: { ...Platform.select({ ios: { heading1: 25, heading2: 25, heading3: 25, heading4: 25, heading5: 25, heading6: 25, text: 20 }, android: { heading1: 60, heading2: 60, heading3: 60, heading4: 60, heading5: 60, heading6: 60, text: 45 } }) }, fontSizes: { heading1: 17, heading2: 17, heading3: 17, heading4: 17, heading5: 17, heading6: 17, text: 15 }, onLongPress: () => true }; constructor(props) { super(props); this.parser = new Parser(); this.renderer = this.createRenderer(); } 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, 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, 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 return {literal}; } renderCodeSpan = ({context, literal}) => { return {literal}; } renderImage = ({linkDestination, reactChildren, context, src}) => { return ( {reactChildren} ); } renderAtMention = ({context, mentionName}) => { return ( ); } renderChannelLink = ({context, channelName}) => { return ( ); } renderEmoji = ({context, emojiName, literal}) => { let size; let fontSize; const headingType = context.find((type) => type.startsWith('heading')); if (headingType) { size = this.props.emojiSizes[headingType]; fontSize = this.props.fontSizes[headingType]; } else { size = this.props.emojiSizes.text; fontSize = this.props.fontSizes.text; } 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 style = getStyleSheet(this.props.theme); 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; } 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 = addListItemIndices(ast); ast = pullOutImages(ast); if (this.props.isEdited) { const editIndicatorNode = new Node('edited_indicator'); if (['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, fontSize: 14, opacity: editedOpacity } }; });