import React, { PureComponent } from 'react'; import { Text, Image } from 'react-native'; import { Parser, Node } from 'commonmark'; import Renderer from 'commonmark-react-renderer'; import PropTypes from 'prop-types'; import removeMarkdown from 'remove-markdown'; import shortnameToUnicode from '../../utils/shortnameToUnicode'; import I18n from '../../i18n'; import { themes } from '../../constants/colors'; import MarkdownLink from './Link'; import MarkdownList from './List'; import MarkdownListItem from './ListItem'; import MarkdownAtMention from './AtMention'; import MarkdownHashtag from './Hashtag'; import MarkdownBlockQuote from './BlockQuote'; import MarkdownEmoji from './Emoji'; import MarkdownTable from './Table'; import MarkdownTableRow from './TableRow'; import MarkdownTableCell from './TableCell'; import mergeTextNodes from './mergeTextNodes'; import styles from './styles'; import { isValidURL } from '../../utils/url'; // Support <http://link|Text> const formatText = text => text.replace( new RegExp('(?:<|<)((?:https|http):\\/\\/[^\\|]+)\\|(.+?)(?=>|>)(?:>|>)', 'gm'), (match, url, title) => `[${ title }](${ url })` ); const emojiRanges = [ '\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]', // unicode emoji from https://www.regextester.com/106421 ':.{1,40}:', // custom emoji ' |\n' // allow spaces and line breaks ].join('|'); const removeSpaces = str => str && str.replace(/\s/g, ''); const removeAllEmoji = str => str.replace(new RegExp(emojiRanges, 'g'), ''); const isOnlyEmoji = (str) => { str = removeSpaces(str); return !removeAllEmoji(str).length; }; const removeOneEmoji = str => str.replace(new RegExp(emojiRanges), ''); const emojiCount = (str) => { str = removeSpaces(str); let oldLength = 0; let counter = 0; while (oldLength !== str.length) { oldLength = str.length; str = removeOneEmoji(str); if (oldLength !== str.length) { counter += 1; } } return counter; }; const parser = new Parser(); class Markdown extends PureComponent { static propTypes = { msg: PropTypes.string, getCustomEmoji: PropTypes.func, baseUrl: PropTypes.string, username: PropTypes.string, tmid: PropTypes.string, isEdited: PropTypes.bool, numberOfLines: PropTypes.number, customEmojis: PropTypes.bool, useRealName: PropTypes.bool, channels: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), mentions: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), navToRoomInfo: PropTypes.func, preview: PropTypes.bool, theme: PropTypes.string, testID: PropTypes.string, style: PropTypes.array }; constructor(props) { super(props); this.renderer = this.createRenderer(); } createRenderer = () => new Renderer({ renderers: { text: this.renderText, emph: Renderer.forwardChildren, strong: Renderer.forwardChildren, del: Renderer.forwardChildren, code: this.renderCodeInline, link: this.renderLink, image: this.renderImage, atMention: this.renderAtMention, 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.renderBreak, thematicBreak: this.renderBreak, softBreak: this.renderBreak, htmlBlock: this.renderText, htmlInline: this.renderText, table: this.renderTable, table_row: this.renderTableRow, table_cell: this.renderTableCell, editedIndicator: this.renderEditedIndicator }, renderParagraphsInLists: true }); editedMessage = (ast) => { const { isEdited } = this.props; 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); } } }; renderText = ({ context, literal }) => { const { numberOfLines, style = [] } = this.props; const defaultStyle = [ this.isMessageContainsOnlyEmoji ? styles.textBig : {}, ...context.map(type => styles[type]) ]; return ( <Text accessibilityLabel={literal} style={[styles.text, defaultStyle, ...style]} numberOfLines={numberOfLines} > {literal} </Text> ); } renderCodeInline = ({ literal }) => { const { theme, style = [] } = this.props; return ( <Text style={[ { ...styles.codeInline, color: themes[theme].bodyText, backgroundColor: themes[theme].bannerBackground, borderColor: themes[theme].bannerBackground }, ...style ]} > {literal} </Text> ); }; renderCodeBlock = ({ literal }) => { const { theme, style = [] } = this.props; return ( <Text style={[ { ...styles.codeBlock, color: themes[theme].bodyText, backgroundColor: themes[theme].bannerBackground, borderColor: themes[theme].bannerBackground }, ...style ]} > {literal} </Text> ); }; renderBreak = () => { const { tmid } = this.props; return <Text>{tmid ? ' ' : '\n'}</Text>; } renderParagraph = ({ children }) => { const { numberOfLines, style, theme } = this.props; if (!children || children.length === 0) { return null; } return ( <Text style={[styles.text, style, { color: themes[theme].bodyText }]} numberOfLines={numberOfLines}> {children} </Text> ); }; renderLink = ({ children, href }) => { const { theme } = this.props; return ( <MarkdownLink link={href} theme={theme} > {children} </MarkdownLink> ); } renderHashtag = ({ hashtag }) => { const { channels, navToRoomInfo, style, theme } = this.props; return ( <MarkdownHashtag hashtag={hashtag} channels={channels} navToRoomInfo={navToRoomInfo} theme={theme} style={style} /> ); } renderAtMention = ({ mentionName }) => { const { username, mentions, navToRoomInfo, useRealName, style, theme } = this.props; return ( <MarkdownAtMention mentions={mentions} mention={mentionName} useRealName={useRealName} username={username} navToRoomInfo={navToRoomInfo} theme={theme} style={style} /> ); } renderEmoji = ({ literal }) => { const { getCustomEmoji, baseUrl, customEmojis, style, theme } = this.props; return ( <MarkdownEmoji literal={literal} isMessageContainsOnlyEmoji={this.isMessageContainsOnlyEmoji} getCustomEmoji={getCustomEmoji} baseUrl={baseUrl} customEmojis={customEmojis} style={style} theme={theme} /> ); } renderImage = ({ src }) => { if (!isValidURL(src)) { return null; } return ( <Image style={styles.inlineImage} source={{ uri: encodeURI(src) }} /> ); } renderEditedIndicator = () => { const { theme } = this.props; return <Text style={[styles.edited, { color: themes[theme].auxiliaryText }]}> ({I18n.t('edited')})</Text>; } renderHeading = ({ children, level }) => { const { numberOfLines, theme } = this.props; const textStyle = styles[`heading${ level }Text`]; return ( <Text numberOfLines={numberOfLines} style={[textStyle, { color: themes[theme].bodyText }]}> {children} </Text> ); }; renderList = ({ children, start, tight, type }) => { const { numberOfLines } = this.props; return ( <MarkdownList ordered={type !== 'bullet'} start={start} tight={tight} numberOfLines={numberOfLines} > {children} </MarkdownList> ); }; renderListItem = ({ children, context, ...otherProps }) => { const { theme } = this.props; const level = context.filter(type => type === 'list').length; return ( <MarkdownListItem level={level} theme={theme} {...otherProps} > {children} </MarkdownListItem> ); }; renderBlockQuote = ({ children }) => { const { theme } = this.props; return ( <MarkdownBlockQuote theme={theme}> {children} </MarkdownBlockQuote> ); } renderTable = ({ children, numColumns }) => { const { theme } = this.props; return ( <MarkdownTable numColumns={numColumns} theme={theme}> {children} </MarkdownTable> ); } renderTableRow = (args) => { const { theme } = this.props; return <MarkdownTableRow {...args} theme={theme} />; } renderTableCell = (args) => { const { theme } = this.props; return <MarkdownTableCell {...args} theme={theme} />; } render() { const { msg, numberOfLines, preview = false, theme, style = [], testID } = this.props; if (!msg) { return null; } let m = formatText(msg); // Ex: '[ ](https://open.rocket.chat/group/test?msg=abcdef) Test' // Return: 'Test' m = m.replace(/^\[([\s]*)\]\(([^)]*)\)\s/, '').trim(); if (preview) { m = shortnameToUnicode(m); // Removes sequential empty spaces m = m.replace(/\s+/g, ' '); m = removeMarkdown(m); m = m.replace(/\n+/g, ' '); return ( <Text accessibilityLabel={m} style={[styles.text, { color: themes[theme].bodyText }, ...style]} numberOfLines={numberOfLines} testID={testID}> {m} </Text> ); } let ast = parser.parse(m); ast = mergeTextNodes(ast); this.isMessageContainsOnlyEmoji = isOnlyEmoji(m) && emojiCount(m) <= 3; this.editedMessage(ast); return this.renderer.render(ast); } } export default Markdown;