import React, { PureComponent } from 'react'; import { Image, StyleProp, Text, TextStyle } from 'react-native'; import { Parser } from 'commonmark'; import Renderer from 'commonmark-react-renderer'; import { MarkdownAST } from '@rocket.chat/message-parser'; 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 '../../lib/methods/helpers/url'; import NewMarkdown from './new'; import { formatText } from './formatText'; import { IUserMention, IUserChannel, TOnLinkPress } from './interfaces'; import { TGetCustomEmoji } from '../../definitions/IEmoji'; import { formatHyperlink } from './formatHyperlink'; import { TSupportedThemes, withTheme } from '../../theme'; import { themes } from '../../lib/constants'; export { default as MarkdownPreview } from './Preview'; interface IMarkdownProps { msg?: string | null; theme?: TSupportedThemes; md?: MarkdownAST; mentions?: IUserMention[]; getCustomEmoji?: TGetCustomEmoji; username?: string; tmid?: string; numberOfLines?: number; customEmojis?: boolean; useRealName?: boolean; channels?: IUserChannel[]; enableMessageParser?: boolean; // TODO: Refactor when migrate Room navToRoomInfo?: Function; testID?: string; style?: StyleProp<TextStyle>[]; onLinkPress?: TOnLinkPress; isTranslated?: boolean; } type TLiteral = { literal: string; }; 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: string) => str && str.replace(/\s/g, ''); const removeAllEmoji = (str: string) => str.replace(new RegExp(emojiRanges, 'g'), ''); const isOnlyEmoji = (str: string) => { str = removeSpaces(str); return !removeAllEmoji(str).length; }; const removeOneEmoji = (str: string) => str.replace(new RegExp(emojiRanges), ''); const emojiCount = (str: string) => { 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<IMarkdownProps, any> { private renderer: any; private isMessageContainsOnlyEmoji!: boolean; constructor(props: IMarkdownProps) { 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 }, renderParagraphsInLists: true }); get isNewMarkdown(): boolean { const { md, enableMessageParser } = this.props; return (enableMessageParser ?? true) && !!md; } renderText = ({ context, literal }: { context: []; literal: string }) => { 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 }: TLiteral) => { 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 }: TLiteral) => { 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 }: any) => { 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 }: any) => { const { theme, onLinkPress } = this.props; return ( <MarkdownLink link={href} theme={theme!} onLinkPress={onLinkPress}> {children} </MarkdownLink> ); }; renderHashtag = ({ hashtag }: { hashtag: string }) => { const { channels, navToRoomInfo, style } = this.props; return <MarkdownHashtag hashtag={hashtag} channels={channels} navToRoomInfo={navToRoomInfo} style={style} />; }; renderAtMention = ({ mentionName }: { mentionName: string }) => { const { username = '', mentions, navToRoomInfo, useRealName, style } = this.props; return ( <MarkdownAtMention mentions={mentions} mention={mentionName} useRealName={useRealName} username={username} navToRoomInfo={navToRoomInfo} style={style} /> ); }; renderEmoji = ({ literal }: TLiteral) => { const { getCustomEmoji, customEmojis, style } = this.props; return ( <MarkdownEmoji literal={literal} isMessageContainsOnlyEmoji={this.isMessageContainsOnlyEmoji} getCustomEmoji={getCustomEmoji} customEmojis={customEmojis} style={style} /> ); }; renderImage = ({ src }: { src: string }) => { if (!isValidURL(src)) { return null; } return <Image style={styles.inlineImage} source={{ uri: encodeURI(src) }} />; }; renderHeading = ({ children, level }: any) => { const { numberOfLines, theme } = this.props; // @ts-ignore const textStyle = styles[`heading${level}Text`]; return ( <Text numberOfLines={numberOfLines} style={[textStyle, { color: themes[theme!].bodyText }]}> {children} </Text> ); }; renderList = ({ children, start, tight, type }: any) => { const { numberOfLines } = this.props; return ( <MarkdownList ordered={type !== 'bullet'} start={start} tight={tight} numberOfLines={numberOfLines}> {children} </MarkdownList> ); }; renderListItem = ({ children, context, ...otherProps }: any) => { const { theme } = this.props; const level = context.filter((type: string) => type === 'list').length; return ( <MarkdownListItem level={level} theme={theme} {...otherProps}> {children} </MarkdownListItem> ); }; renderBlockQuote = ({ children }: { children: JSX.Element }) => { const { theme } = this.props; return <MarkdownBlockQuote theme={theme!}>{children}</MarkdownBlockQuote>; }; renderTable = ({ children, numColumns }: { children: JSX.Element; numColumns: number }) => { const { theme } = this.props; return ( <MarkdownTable numColumns={numColumns} theme={theme!}> {children} </MarkdownTable> ); }; renderTableRow = (args: any) => { const { theme } = this.props; return <MarkdownTableRow {...args} theme={theme} />; }; renderTableCell = (args: any) => { const { theme } = this.props; return <MarkdownTableCell {...args} theme={theme} />; }; render() { const { msg, md, mentions, channels, navToRoomInfo, useRealName, username = '', getCustomEmoji, onLinkPress, isTranslated } = this.props; if (!msg) { return null; } if (this.isNewMarkdown && !isTranslated) { return ( <NewMarkdown username={username} getCustomEmoji={getCustomEmoji} useRealName={useRealName} tokens={md} mentions={mentions} channels={channels} navToRoomInfo={navToRoomInfo} onLinkPress={onLinkPress} /> ); } let m = formatText(msg); m = formatHyperlink(m); let ast = parser.parse(m); ast = mergeTextNodes(ast); this.isMessageContainsOnlyEmoji = isOnlyEmoji(m) && emojiCount(m) <= 3; return this.renderer?.render(ast) || null; } } export default withTheme(Markdown);