diff --git a/app/components/markdown/index.js b/app/components/markdown/index.js index e015d57e6..9ed89ddf4 100644 --- a/app/components/markdown/index.js +++ b/app/components/markdown/index.js @@ -201,15 +201,15 @@ export default class Markdown extends PureComponent { renderCodeBlock = (props) => { // These sometimes include a trailing newline - const contents = props.literal.replace(/\n$/, ''); + const content = props.literal.replace(/\n$/, ''); return ( - {contents} - + navigator={this.props.navigator} + content={content} + language={props.language} + textStyle={this.props.textStyles.codeBlock} + /> ); } diff --git a/app/components/markdown/markdown_code_block.js b/app/components/markdown/markdown_code_block.js deleted file mode 100644 index e5ea95ef1..000000000 --- a/app/components/markdown/markdown_code_block.js +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2017-present 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_code_block/index.js b/app/components/markdown/markdown_code_block/index.js new file mode 100644 index 000000000..d71e7bc8a --- /dev/null +++ b/app/components/markdown/markdown_code_block/index.js @@ -0,0 +1,17 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; + +import {getTheme} from 'app/selectors/preferences'; + +import MarkdownCodeBlock from './markdown_code_block'; + +function mapStateToProps(state, ownProps) { + return { + theme: getTheme(state), + ...ownProps + }; +} + +export default connect(mapStateToProps)(MarkdownCodeBlock); diff --git a/app/components/markdown/markdown_code_block/markdown_code_block.js b/app/components/markdown/markdown_code_block/markdown_code_block.js new file mode 100644 index 000000000..17fc7946d --- /dev/null +++ b/app/components/markdown/markdown_code_block/markdown_code_block.js @@ -0,0 +1,215 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {PropTypes} from 'prop-types'; +import React from 'react'; +import {injectIntl, intlShape} from 'react-intl'; +import { + StyleSheet, + Text, + TouchableOpacity, + View +} from 'react-native'; + +import CustomPropTypes from 'app/constants/custom_prop_types'; +import FormattedText from 'app/components/formatted_text'; +import {getDisplayNameForLanguage} from 'app/utils/markdown'; +import {wrapWithPreventDoubleTap} from 'app/utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +const MAX_LINES = 4; + +class MarkdownCodeBlock extends React.PureComponent { + static propTypes = { + intl: intlShape.isRequired, + navigator: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired, + language: PropTypes.string, + content: PropTypes.string.isRequired, + textStyle: CustomPropTypes.Style + }; + + static defaultProps = { + language: '' + }; + + handlePress = wrapWithPreventDoubleTap(() => { + const {intl, navigator, theme} = this.props; + + const languageDisplayName = getDisplayNameForLanguage(this.props.language); + let title; + if (languageDisplayName) { + title = intl.formatMessage( + { + id: 'mobile.routes.code', + defaultMessage: '{language} Code' + }, + { + language: languageDisplayName + } + ); + } else { + title = intl.formatMessage({ + id: 'mobile.routes.code.noLanguage', + defaultMessage: 'Code' + }); + } + + navigator.push({ + screen: 'Code', + title, + animated: true, + backButtonTitle: '', + passProps: { + content: this.props.content + }, + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg + } + }); + }); + + trimContent = (content) => { + const lines = content.split('\n'); + const numberOfLines = lines.length; + + if (numberOfLines > MAX_LINES) { + return { + content: lines.slice(0, MAX_LINES).join('\n'), + numberOfLines + }; + } + + return { + content, + numberOfLines + }; + }; + + render() { + const style = getStyleSheet(this.props.theme); + + let language = null; + if (this.props.language) { + const languageDisplayName = getDisplayNameForLanguage(this.props.language); + + if (languageDisplayName) { + language = ( + + + {languageDisplayName} + + + ); + } + } + + const {content, numberOfLines} = this.trimContent(this.props.content); + + let lineNumbers = '1'; + for (let i = 1; i < Math.min(numberOfLines, MAX_LINES); i++) { + const line = (i + 1).toString(); + + lineNumbers += '\n' + line; + } + + let plusMoreLines = null; + if (numberOfLines > MAX_LINES) { + plusMoreLines = ( + + ); + } + + return ( + + + + + {lineNumbers} + + + + + + {content} + + + {plusMoreLines} + + {language} + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + container: { + borderColor: changeOpacity(theme.centerChannelColor, 0.15), + borderRadius: 3, + borderWidth: StyleSheet.hairlineWidth, + flexDirection: 'row' + }, + lineNumbers: { + alignItems: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.05), + borderRightColor: changeOpacity(theme.centerChannelColor, 0.15), + borderRightWidth: StyleSheet.hairlineWidth, + flexDirection: 'column', + justifyContent: 'flex-start', + paddingVertical: 4, + width: 21 + }, + lineNumbersText: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 12, + lineHeight: 18 + }, + rightColumn: { + flexDirection: 'column', + flex: 1, + paddingHorizontal: 6, + paddingVertical: 4 + }, + code: { + flexDirection: 'row', + overflow: 'scroll' // Doesn't actually cause a scrollbar, but stops text from wrapping + }, + codeText: { + fontSize: 12, + lineHeight: 18 + }, + plusMoreLinesText: { + color: changeOpacity(theme.centerChannelColor, 0.4), + fontSize: 11, + marginTop: 2 + }, + language: { + alignItems: 'center', + backgroundColor: theme.sidebarHeaderBg, + justifyContent: 'center', + opacity: 0.8, + padding: 6, + position: 'absolute', + right: 0, + top: 0 + }, + languageText: { + color: theme.sidebarHeaderTextColor, + fontSize: 12 + } + }; +}); + +export default injectIntl(MarkdownCodeBlock); diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index 3972e49fe..280b53565 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -43,7 +43,8 @@ export default class PostList extends PureComponent { }; static defaultProps = { - channel: {} + channel: {}, + channelIsLoading: false }; getPostsWithDates = () => { diff --git a/app/screens/code/code.js b/app/screens/code/code.js new file mode 100644 index 000000000..b8d73ae37 --- /dev/null +++ b/app/screens/code/code.js @@ -0,0 +1,115 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import PropTypes from 'prop-types'; +import React from 'react'; +import { + Dimensions, + ScrollView, + StyleSheet, + Text, + View +} from 'react-native'; + +import {getCodeFont} from 'app/utils/markdown'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +const { + width: deviceWidth +} = Dimensions.get('window'); + +export default class Code extends React.PureComponent { + static propTypes = { + theme: PropTypes.object.isRequired, + content: PropTypes.string.isRequired + }; + + countLines = (content) => { + return content.split('\n').length; + } + + render() { + const style = getStyleSheet(this.props.theme); + + const numberOfLines = this.countLines(this.props.content); + let lineNumbers = '1'; + for (let i = 1; i < numberOfLines; i++) { + const line = (i + 1).toString(); + + lineNumbers += '\n' + line; + } + + let lineNumbersStyle; + if (numberOfLines >= 10) { + lineNumbersStyle = [style.lineNumbers, style.lineNumbersRight]; + } else { + lineNumbersStyle = style.lineNumbers; + } + + return ( + + + + {lineNumbers} + + + + + {this.props.content} + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + scrollContainer: { + flex: 1 + }, + container: { + minHeight: '100%', + flexDirection: 'row' + }, + lineNumbers: { + alignItems: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.05), + borderRightColor: changeOpacity(theme.centerChannelColor, 0.15), + borderRightWidth: StyleSheet.hairlineWidth, + flexDirection: 'column', + justifyContent: 'flex-start', + paddingHorizontal: 6, + paddingVertical: 4 + }, + lineNumbersRight: { + alignItems: 'flex-end' + }, + lineNumbersText: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 12, + lineHeight: 18 + }, + codeContainer: { + flexGrow: 0, + flexShrink: 1, + width: deviceWidth + }, + code: { + paddingHorizontal: 6, + paddingVertical: 4 + }, + codeText: { + fontFamily: getCodeFont(), + fontSize: 12, + lineHeight: 18 + } + }; +}); diff --git a/app/screens/code/index.js b/app/screens/code/index.js new file mode 100644 index 000000000..900b136bb --- /dev/null +++ b/app/screens/code/index.js @@ -0,0 +1,25 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; + +import {getTheme} from 'app/selectors/preferences'; + +import Code from './code'; + +function mapStateToProps(state, ownProps) { + return { + theme: getTheme(state), + ...ownProps + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(Code); diff --git a/app/screens/index.js b/app/screens/index.js index 63c64a151..0f486d86e 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -12,6 +12,7 @@ import Channel from 'app/screens/channel'; import ChannelAddMembers from 'app/screens/channel_add_members'; import ChannelInfo from 'app/screens/channel_info'; import ChannelMembers from 'app/screens/channel_members'; +import Code from 'app/screens/code'; import CreateChannel from 'app/screens/create_channel'; import EditPost from 'app/screens/edit_post'; import ImagePreview from 'app/screens/image_preview'; @@ -57,6 +58,7 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('ChannelAddMembers', () => wrapWithContextProvider(ChannelAddMembers), store, Provider); Navigation.registerComponent('ChannelInfo', () => wrapWithContextProvider(ChannelInfo), store, Provider); Navigation.registerComponent('ChannelMembers', () => wrapWithContextProvider(ChannelMembers), store, Provider); + Navigation.registerComponent('Code', () => wrapWithContextProvider(Code), store, Provider); Navigation.registerComponent('CreateChannel', () => wrapWithContextProvider(CreateChannel), store, Provider); Navigation.registerComponent('EditPost', () => wrapWithContextProvider(EditPost), store, Provider); Navigation.registerComponent('ImagePreview', () => wrapWithContextProvider(ImagePreview), store, Provider); diff --git a/app/utils/markdown.js b/app/utils/markdown.js index e27d60e00..69ff1f71c 100644 --- a/app/utils/markdown.js +++ b/app/utils/markdown.js @@ -4,8 +4,12 @@ import {Platform, StyleSheet} from 'react-native'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; +export function getCodeFont() { + return Platform.OS === 'ios' ? 'Menlo' : 'monospace'; +} + export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme) => { - const codeFont = Platform.OS === 'ios' ? 'Menlo' : 'monospace'; + const codeFont = getCodeFont(); return { emph: { @@ -75,12 +79,6 @@ export const getMarkdownBlockStyles = makeStyleSheetFromTheme((theme) => { adjacentParagraph: { marginTop: 6 }, - codeBlock: { - backgroundColor: changeOpacity(theme.centerChannelColor, 0.07), - borderRadius: 4, - paddingHorizontal: 4, - paddingVertical: 2 - }, horizontalRule: { backgroundColor: theme.centerChannelColor, height: StyleSheet.hairlineWidth, @@ -94,7 +92,67 @@ export const getMarkdownBlockStyles = makeStyleSheetFromTheme((theme) => { }; }); -export default { - getMarkdownBlockStyles, - getMarkdownTextStyles +const languages = { + actionscript: 'ActionScript', + applescript: 'AppleScript', + bash: 'Bash', + clojure: 'Clojure', + coffeescript: 'CoffeeScript', + cpp: 'C++', + cs: 'C#', + css: 'CSS', + d: 'D', + dart: 'Dart', + delphi: 'Delphi', + diff: 'Diff', + django: 'Django', + dockerfile: 'Dockerfile', + erlang: 'Erlang', + fortran: 'Fortran', + fsharp: 'F#', + gcode: 'G-code', + go: 'Go', + groovy: 'Groovy', + handlebars: 'Handlebars', + haskell: 'Haskell', + haxe: 'Haxe', + html: 'HTML', + java: 'Java', + javascript: 'JavaScript', + json: 'JSON', + julia: 'Julia', + kotlin: 'Kotlin', + latex: 'LaTeX', + less: 'Less', + lisp: 'Lisp', + lua: 'Lua', + makefile: 'Makefile', + markdown: 'Markdown', + matlab: 'Matlab', + objectivec: 'Objective-C', + ocaml: 'OCaml', + perl: 'Perl', + php: 'PHP', + powershell: 'PowerShell', + puppet: 'Puppet', + python: 'Python', + r: 'R', + ruby: 'Ruby', + rust: 'Rust', + scala: 'Scala', + scheme: 'Scheme', + scss: 'SCSS', + smalltalk: 'Smalltalk', + sql: 'SQL', + swift: 'Swift', + tex: 'TeX', + vbnet: 'VB.NET', + vbscript: 'VBScript', + verilog: 'Verilog', + xml: 'XML', + yaml: 'YAML' }; + +export function getDisplayNameForLanguage(language) { + return languages[language.toLowerCase()] || ''; +} diff --git a/app/utils/tap.js b/app/utils/tap.js index e87255f62..d8df863df 100644 --- a/app/utils/tap.js +++ b/app/utils/tap.js @@ -1,6 +1,8 @@ // Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +const doublePressDelay = 300; + let canPress = true; export function preventDoubleTap(action, thisArg, ...args) { if (canPress) { @@ -9,6 +11,21 @@ export function preventDoubleTap(action, thisArg, ...args) { setTimeout(() => { canPress = true; - }, 300); + }, doublePressDelay); } } + +export function wrapWithPreventDoubleTap(func) { + let canPressWrapped = true; + + return (...args) => { + if (canPressWrapped) { + canPressWrapped = false; + func(...args); + + setTimeout(() => { + canPressWrapped = true; + }, doublePressDelay); + } + }; +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index de26667a7..ecae5a3df 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1849,6 +1849,7 @@ "mobile.managed.exit": "Exit", "mobile.managed.jailbreak": "Jailbroken devices are not trusted by {vendor}, please exit the app.", "mobile.managed.secured_by": "Secured by {vendor}", + "mobile.markdown.code.plusMoreLines": "+{count, number} more lines", "mobile.more_dms.start": "Start", "mobile.more_dms.title": "New Conversation", "mobile.notice_mobile_link": "mobile apps", @@ -1876,6 +1877,8 @@ "mobile.routes.channel_members.action_message": "You must select at least one member to remove from the channel.", "mobile.routes.channel_members.action_message_confirm": "Are you sure you want to remove the selected members from the channel?", "mobile.routes.channels": "Channels", + "mobile.routes.code": "{language} Code", + "mobile.routes.code.noLanguage": "Code", "mobile.routes.enterServerUrl": "Enter Server URL", "mobile.routes.login": "Login", "mobile.routes.loginOptions": "Login Chooser",