From 07af08b74a3a90554cbbf6e8d9e5dfcd9ea26552 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Mon, 20 Mar 2017 15:27:33 -0400 Subject: [PATCH] PLT-5717 Initial markdown support (#354) * Added CommonMark-based post rendering * Fixed text wrapping in most block elements * Fixed paragraphs not being rendered as children of lists * Replaced markdown images with a placeholder * Fixed font colour in code spans --- app/components/markdown/markdown.js | 195 ++++++++++++++++++ .../markdown/markdown_block_quote.js | 41 ++++ .../markdown/markdown_code_block.js | 28 +++ app/components/markdown/markdown_image.js | 69 +++++++ app/components/markdown/markdown_link.js | 28 +++ app/components/markdown/markdown_list.js | 31 +++ app/components/markdown/markdown_list_item.js | 55 +++++ app/components/post/post.js | 111 ++++++++-- app/constants/custom_prop_types.js | 17 ++ app/utils/theme.js | 4 + package.json | 2 + 11 files changed, 565 insertions(+), 16 deletions(-) create mode 100644 app/components/markdown/markdown.js create mode 100644 app/components/markdown/markdown_block_quote.js create mode 100644 app/components/markdown/markdown_code_block.js create mode 100644 app/components/markdown/markdown_image.js create mode 100644 app/components/markdown/markdown_link.js create mode 100644 app/components/markdown/markdown_list.js create mode 100644 app/components/markdown/markdown_list_item.js create mode 100644 app/constants/custom_prop_types.js diff --git a/app/components/markdown/markdown.js b/app/components/markdown/markdown.js new file mode 100644 index 000000000..34994e390 --- /dev/null +++ b/app/components/markdown/markdown.js @@ -0,0 +1,195 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {Parser} from 'commonmark'; +import Renderer from 'commonmark-react-renderer'; +import React, {PropTypes, PureComponent} from 'react'; +import { + StyleSheet, + Text, + View +} from 'react-native'; + +import CustomPropTypes from 'app/constants/custom_prop_types'; +import {concatStyles} from 'app/utils/theme'; + +import MarkdownBlockQuote from './markdown_block_quote'; +import MarkdownCodeBlock from './markdown_code_block'; +import MarkdownLink from './markdown_link'; +import MarkdownList from './markdown_list'; +import MarkdownListItem from './markdown_list_item'; + +export default class Markdown extends PureComponent { + static propTypes = { + baseTextStyle: CustomPropTypes.Style, + textStyles: PropTypes.object, + blockStyles: PropTypes.object, + value: PropTypes.string.isRequired + }; + + static defaultProps = { + textStyles: {}, + blockStyles: {} + }; + + 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, + code: this.renderCodeSpan, + link: MarkdownLink, + image: this.renderImage, + + 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 + }, + renderParagraphsInLists: true + }); + } + + computeTextStyle = (baseStyle, context) => { + return concatStyles(baseStyle, context.map((type) => this.props.textStyles[type])); + } + + renderText = ({context, 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 = ({children, context, src}) => { + // TODO This will be properly implemented for PLT-5736 + return ( + + {'!['} + {children} + {']('} + {src} + {')'} + + ); + } + + renderParagraph = ({children}) => { + return ( + + + {children} + + + ); + } + + renderHeading = ({children, level}) => { + return ( + + + {children} + + + ); + } + + renderCodeBlock = (props) => { + // These sometimes include a trailing newline + const contents = props.literal.replace(/\n$/, ''); + + return ( + + {contents} + + ); + } + + renderBlockQuote = ({children, ...otherProps}) => { + return ( + + {children} + + ); + } + + renderList = ({children, start, tight, type}) => { + return ( + + {children} + + ); + } + + renderListItem = ({children, ...otherProps}) => { + return ( + + {children} + + ); + } + + renderHardBreak = () => { + return ; + } + + renderThematicBreak = () => { + return ; + } + + renderSoftBreak = () => { + return {'\n'}; + } + + renderHtml = () => { + return null; + } + + render() { + const ast = this.parser.parse(this.props.value); + + return {this.renderer.render(ast)}; + } +} + +const style = StyleSheet.create({ + block: { + alignItems: 'flex-start', + flexDirection: 'row', + flexWrap: 'wrap' + } +}); diff --git a/app/components/markdown/markdown_block_quote.js b/app/components/markdown/markdown_block_quote.js new file mode 100644 index 000000000..7856972ce --- /dev/null +++ b/app/components/markdown/markdown_block_quote.js @@ -0,0 +1,41 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import { + StyleSheet, + Text, + View +} from 'react-native'; + +import CustomPropTypes from 'app/constants/custom_prop_types'; + +export default class MarkdownBlockQuote extends PureComponent { + static propTypes = { + blockStyle: CustomPropTypes.Style, + bulletStyle: CustomPropTypes.Style, + children: CustomPropTypes.Children.isRequired + }; + + render() { + return ( + + + + {'> '} + + + + {this.props.children} + + + ); + } +} + +const style = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'flex-start' + } +}); diff --git a/app/components/markdown/markdown_code_block.js b/app/components/markdown/markdown_code_block.js new file mode 100644 index 000000000..55e5702f7 --- /dev/null +++ b/app/components/markdown/markdown_code_block.js @@ -0,0 +1,28 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PureComponent} from 'react'; +import {ScrollView, Text} from 'react-native'; + +import CustomPropTypes from 'app/constants/custom_prop_types'; + +export default class MarkdownCodeBlock extends PureComponent { + static propTypes = { + children: CustomPropTypes.Children, + blockStyle: CustomPropTypes.Style, + textStyle: CustomPropTypes.Style + }; + + render() { + return ( + + + {this.props.children} + + + ); + } +} diff --git a/app/components/markdown/markdown_image.js b/app/components/markdown/markdown_image.js new file mode 100644 index 000000000..b9c0895ab --- /dev/null +++ b/app/components/markdown/markdown_image.js @@ -0,0 +1,69 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PropTypes, PureComponent} from 'react'; +import {Image} from 'react-native'; + +export default class MarkdownLink extends PureComponent { + static propTypes = { + src: PropTypes.string.isRequired + }; + + constructor(props) { + super(props); + + this.state = { + width: 10000, + maxWidth: 10000, + height: 0 + }; + } + + componentWillMount() { + Image.getSize(this.props.src, this.handleSizeReceived, this.handleSizeFailed); + } + + componentWillReceiveProps(nextProps) { + if (this.props.src !== nextProps.src) { + Image.getSize(nextProps.src, this.handleSizeReceived, this.handleSizeFailed); + } + } + + handleSizeReceived = (width, height) => { + this.setState({ + width, + height + }); + }; + + handleSizeFailed = () => { + this.setState({ + width: 0, + height: 0 + }); + } + + handleLayout = (event) => { + this.setState({ + maxWidth: event.nativeEvent.layout.width + }); + } + + render() { + let {width, maxWidth, height} = this.state; // eslint-disable-line prefer-const + + if (width > maxWidth) { + height = height * (maxWidth / width); + width = maxWidth; + } + + // React Native complains if we try to pass resizeMode into a StyleSheet + return ( + + ); + } +} diff --git a/app/components/markdown/markdown_link.js b/app/components/markdown/markdown_link.js new file mode 100644 index 000000000..c6f751b2b --- /dev/null +++ b/app/components/markdown/markdown_link.js @@ -0,0 +1,28 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PropTypes, PureComponent} from 'react'; +import {Linking, Text} from 'react-native'; + +import CustomPropTypes from 'app/constants/custom_prop_types'; + +export default class MarkdownLink extends PureComponent { + static propTypes = { + children: CustomPropTypes.Children.isRequired, + href: PropTypes.string.isRequired + }; + + handlePress = () => { + const url = this.props.href; + + Linking.canOpenURL(url).then((supported) => { + if (supported) { + Linking.openURL(url); + } + }); + }; + + render() { + return {this.props.children}; + } +} diff --git a/app/components/markdown/markdown_list.js b/app/components/markdown/markdown_list.js new file mode 100644 index 000000000..4ea3fe346 --- /dev/null +++ b/app/components/markdown/markdown_list.js @@ -0,0 +1,31 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PropTypes, PureComponent} from 'react'; +import {View} from 'react-native'; + +export default class MarkdownList extends PureComponent { + static propTypes = { + children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf([PropTypes.node])]).isRequired, + ordered: PropTypes.bool.isRequired, + startAt: PropTypes.number, + tight: PropTypes.bool + }; + + render() { + const children = React.Children.map(this.props.children, (child, i) => { + return React.cloneElement(child, { + ordered: this.props.ordered, + startAt: this.props.startAt, + index: i, + tight: this.props.tight + }); + }); + + return ( + + {children} + + ); + } +} diff --git a/app/components/markdown/markdown_list_item.js b/app/components/markdown/markdown_list_item.js new file mode 100644 index 000000000..19bb442f8 --- /dev/null +++ b/app/components/markdown/markdown_list_item.js @@ -0,0 +1,55 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PropTypes, PureComponent} from 'react'; +import { + StyleSheet, + Text, + View +} from 'react-native'; + +import CustomPropTypes from 'app/constants/custom_prop_types'; + +export default class MarkdownListItem extends PureComponent { + static propTypes = { + children: CustomPropTypes.Children.isRequired, + ordered: PropTypes.bool.isRequired, + startAt: PropTypes.number, + index: PropTypes.number.isRequired, + tight: PropTypes.bool, + bulletStyle: CustomPropTypes.Style + }; + + static defaultProps = { + startAt: 1 + }; + + render() { + let bullet; + if (this.props.ordered) { + bullet = (this.props.startAt + this.props.index) + '. '; + } else { + bullet = '• '; + } + + return ( + + + + {bullet} + + + + {this.props.children} + + + ); + } +} + +const style = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'flex-start' + } +}); diff --git a/app/components/post/post.js b/app/components/post/post.js index 87411074a..56a77516e 100644 --- a/app/components/post/post.js +++ b/app/components/post/post.js @@ -3,8 +3,8 @@ import React, {Component, PropTypes} from 'react'; import { - Platform, Image, + Platform, StyleSheet, Text, TouchableHighlight, @@ -15,9 +15,10 @@ import { import FormattedText from 'app/components/formatted_text'; import FormattedTime from 'app/components/formatted_time'; import MattermostIcon from 'app/components/mattermost_icon'; +import Markdown from 'app/components/markdown/markdown'; import ProfilePicture from 'app/components/profile_picture'; import FileAttachmentList from 'app/components/file_attachment_list/file_attachment_list_container'; -import {makeStyleSheetFromTheme} from 'app/utils/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import {isSystemMessage} from 'mattermost-redux/utils/post_utils.js'; @@ -117,15 +118,25 @@ export default class Post extends Component { }; renderMessage = (style, messageStyle, replyBar = false) => { + let contents; + if (this.props.post.message.length > 0) { + const theme = this.props.theme; + + contents = ( + + ); + } + return ( {replyBar && this.renderReplyBar(style)} - {this.props.post.message.length > 0 && - - {this.props.post.message} - - } + {contents} {this.renderFileAttachments()} @@ -133,7 +144,9 @@ export default class Post extends Component { }; render() { - const style = getStyleSheet(this.props.theme); + const theme = this.props.theme; + const style = getStyleSheet(theme); + const PROFILE_PICTURE_SIZE = 32; let profilePicture; @@ -178,7 +191,8 @@ export default class Post extends Component { {this.props.post.props.override_username} ); - messageStyle = [style.message, style.webhookMessage]; + + messageStyle = style.message; } else { profilePicture = ( @@ -323,16 +337,81 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { message: { color: theme.centerChannelColor, fontSize: 14, - lineHeight: 21, - marginBottom: 10 + lineHeight: 21 }, systemMessage: { opacity: 0.5 - }, - webhookMessage: { - color: theme.centerChannelColor, - fontSize: 16, - fontWeight: '600' + } + }); +}); + +const getMarkdownTextStyles = makeStyleSheetFromTheme((theme) => { + const codeFont = Platform.OS === 'ios' ? 'Courier New' : 'monospace'; + + return StyleSheet.create({ + emph: { + fontStyle: 'italic' + }, + strong: { + fontWeight: 'bold' + }, + link: { + color: theme.linkColor + }, + heading1: { + fontSize: 30, + lineHeight: 45 + }, + heading2: { + fontSize: 24, + lineHeight: 36 + }, + heading3: { + fontSize: 20, + lineHeight: 30 + }, + heading4: { + fontSize: 16, + lineHeight: 24 + }, + heading5: { + fontSize: 14, + lineHeight: 21 + }, + heading6: { + fontSize: 14, + lineHeight: 21, + opacity: 0.8 + }, + code: { + alignSelf: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + fontFamily: codeFont, + paddingHorizontal: 4, + paddingVertical: 2 + }, + codeBlock: { + fontFamily: codeFont + }, + horizontalRule: { + backgroundColor: theme.centerChannelColor, + height: StyleSheet.hairlineWidth, + flex: 1, + marginVertical: 10 + } + }); +}); + +const getMarkdownBlockStyles = makeStyleSheetFromTheme((theme) => { + return StyleSheet.create({ + codeBlock: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + borderRadius: 4, + paddingHorizontal: 4, + paddingVertical: 2 + }, + horizontalRule: { + backgroundColor: theme.centerChannelColor } }); }); diff --git a/app/constants/custom_prop_types.js b/app/constants/custom_prop_types.js new file mode 100644 index 000000000..fdf0e72a6 --- /dev/null +++ b/app/constants/custom_prop_types.js @@ -0,0 +1,17 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {PropTypes} from 'react'; + +export const Children = PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf([PropTypes.node])]); + +export const Style = PropTypes.oneOfType([ + PropTypes.object, // inline style + PropTypes.number, // style sheet entry + PropTypes.array +]); + +export default { + Children, + Style +}; diff --git a/app/utils/theme.js b/app/utils/theme.js index f46af4f18..5852c570e 100644 --- a/app/utils/theme.js +++ b/app/utils/theme.js @@ -36,3 +36,7 @@ export function changeOpacity(oldColor, opacity) { return 'rgba(' + r + ',' + g + ',' + b + ',' + opacity + ')'; } + +export function concatStyles(...styles) { + return [].concat(styles); +} diff --git a/package.json b/package.json index a086868fb..2e6f827d9 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "0.0.1", "private": true, "dependencies": { + "commonmark": "0.27.0", + "commonmark-react-renderer": "hmhealey/commonmark-react-renderer#2e8ea6ac67b683b44782f403b69a06bb0e5c63e6", "deep-equal": "1.0.1", "harmony-reflect": "1.5.1", "intl": "1.2.5",