From 2f5201bac91be779d9113614c85338455694b9a1 Mon Sep 17 00:00:00 2001 From: Chris Duarte Date: Tue, 31 Oct 2017 09:42:56 -0700 Subject: [PATCH] Add copy post/url/mention/code to tooltip menu (#1069) * Add copy post/url/mention/code to tooltip menu * Check for managedConfig.copyAndPasteProtection * Reorder copy options --- app/components/at_mention/at_mention.js | 43 ++++++++++++++++--- app/components/markdown/index.js | 2 + .../markdown_code_block.js | 30 ++++++++++++- app/components/markdown/markdown_link.js | 31 +++++++++++-- .../options_context.android.js | 18 +++++--- .../options_context/options_context.ios.js | 28 ++++++++++-- app/components/post/post.js | 16 ++++++- app/components/post_body/post_body.js | 29 +++++++++---- .../post_body_additional_content.js | 9 +++- app/components/post_list/post_list.js | 30 +++++++++++++ app/components/slack_attachments/index.js | 6 ++- .../slack_attachments/slack_attachment.js | 8 +++- .../mattermost-managed.android.js | 14 ++++++ .../mattermost-managed.ios.js | 13 ++++++ assets/base/i18n/en.json | 4 ++ 15 files changed, 246 insertions(+), 35 deletions(-) diff --git a/app/components/at_mention/at_mention.js b/app/components/at_mention/at_mention.js index 72f961734..8ac5fb234 100644 --- a/app/components/at_mention/at_mention.js +++ b/app/components/at_mention/at_mention.js @@ -3,24 +3,29 @@ import React from 'react'; import PropTypes from 'prop-types'; -import {Text} from 'react-native'; -import {injectIntl, intlShape} from 'react-intl'; +import {Clipboard, Text} from 'react-native'; +import {intlShape} from 'react-intl'; import CustomPropTypes from 'app/constants/custom_prop_types'; +import mattermostManaged from 'app/mattermost_managed'; -class AtMention extends React.PureComponent { +export default class AtMention extends React.PureComponent { static propTypes = { - intl: intlShape, isSearchResult: PropTypes.bool, mentionName: PropTypes.string.isRequired, mentionStyle: CustomPropTypes.Style, navigator: PropTypes.object.isRequired, + onLongPress: PropTypes.func.isRequired, onPostPress: PropTypes.func, textStyle: CustomPropTypes.Style, theme: PropTypes.object.isRequired, usersByUsername: PropTypes.object.isRequired }; + static contextTypes = { + intl: intlShape + } + constructor(props) { super(props); @@ -42,7 +47,8 @@ class AtMention extends React.PureComponent { } goToUserProfile = () => { - const {intl, navigator, theme} = this.props; + const {navigator, theme} = this.props; + const {intl} = this.context; navigator.push({ screen: 'UserProfile', @@ -86,6 +92,30 @@ class AtMention extends React.PureComponent { }; } + handleLongPress = async () => { + const {intl} = this.context; + + const config = await mattermostManaged.getLocalConfig(); + + let action; + if (config.copyAndPasteProtection !== 'false') { + action = { + text: intl.formatMessage({ + id: 'mobile.mention.copy_mention', + defaultMessage: 'Copy Mention' + }), + onPress: this.handleCopyMention + }; + } + + this.props.onLongPress(action); + } + + handleCopyMention = () => { + const {username} = this.state; + Clipboard.setString(`@${username}`); + } + render() { const {isSearchResult, mentionName, mentionStyle, onPostPress, textStyle} = this.props; const username = this.state.username; @@ -100,6 +130,7 @@ class AtMention extends React.PureComponent { {'@' + username} @@ -109,5 +140,3 @@ class AtMention extends React.PureComponent { ); } } - -export default injectIntl(AtMention); diff --git a/app/components/markdown/index.js b/app/components/markdown/index.js index 39dc110b8..00982a043 100644 --- a/app/components/markdown/index.js +++ b/app/components/markdown/index.js @@ -149,6 +149,7 @@ export default class Markdown extends PureComponent { textStyle={this.computeTextStyle(this.props.baseTextStyle, context)} isSearchResult={this.props.isSearchResult} mentionName={mentionName} + onLongPress={this.props.onLongPress} onPostPress={this.props.onPostPress} navigator={this.props.navigator} /> @@ -223,6 +224,7 @@ export default class Markdown extends PureComponent { content={content} language={props.language} textStyle={this.props.textStyles.codeBlock} + onLongPress={this.props.onLongPress} /> ); } diff --git a/app/components/markdown/markdown_code_block/markdown_code_block.js b/app/components/markdown/markdown_code_block/markdown_code_block.js index 422c6c240..66c3f2cc3 100644 --- a/app/components/markdown/markdown_code_block/markdown_code_block.js +++ b/app/components/markdown/markdown_code_block/markdown_code_block.js @@ -5,6 +5,7 @@ import {PropTypes} from 'prop-types'; import React from 'react'; import {injectIntl, intlShape} from 'react-intl'; import { + Clipboard, StyleSheet, Text, TouchableOpacity, @@ -16,6 +17,7 @@ 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'; +import mattermostManaged from 'app/mattermost_managed'; const MAX_LINES = 4; @@ -26,7 +28,8 @@ class MarkdownCodeBlock extends React.PureComponent { theme: PropTypes.object.isRequired, language: PropTypes.string, content: PropTypes.string.isRequired, - textStyle: CustomPropTypes.Style + textStyle: CustomPropTypes.Style, + onLongPress: PropTypes.func.isRequired }; static defaultProps = { @@ -72,6 +75,26 @@ class MarkdownCodeBlock extends React.PureComponent { }); }); + handleLongPress = async () => { + const {formatMessage} = this.props.intl; + + const config = await mattermostManaged.getLocalConfig(); + + let action; + if (config.copyAndPasteProtection !== 'true') { + action = { + text: formatMessage({id: 'mobile.markdown.code.copy_code', defaultMessage: 'Copy Code'}), + onPress: this.handleCopyCode + }; + } + + this.props.onLongPress(action); + } + + handleCopyCode = () => { + Clipboard.setString(this.props.content); + } + trimContent = (content) => { const lines = content.split('\n'); const numberOfLines = lines.length; @@ -131,7 +154,10 @@ class MarkdownCodeBlock extends React.PureComponent { } return ( - + diff --git a/app/components/markdown/markdown_link.js b/app/components/markdown/markdown_link.js index 6bc9b115b..c9847e8f5 100644 --- a/app/components/markdown/markdown_link.js +++ b/app/components/markdown/markdown_link.js @@ -3,16 +3,19 @@ import React, {Children, PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {Linking, Text} from 'react-native'; +import {Clipboard, Linking, Text} from 'react-native'; import urlParse from 'url-parse'; +import {injectIntl, intlShape} from 'react-intl'; import CustomPropTypes from 'app/constants/custom_prop_types'; import Config from 'assets/config'; +import mattermostManaged from 'app/mattermost_managed'; -export default class MarkdownLink extends PureComponent { +class MarkdownLink extends PureComponent { static propTypes = { children: CustomPropTypes.Children.isRequired, href: PropTypes.string.isRequired, + intl: intlShape.isRequired, onLongPress: PropTypes.func }; @@ -65,16 +68,38 @@ export default class MarkdownLink extends PureComponent { }); } + handleLongPress = async () => { + const {formatMessage} = this.props.intl; + + const config = await mattermostManaged.getLocalConfig(); + + let action; + if (config.copyAndPasteProtection !== 'true') { + action = { + text: formatMessage({id: 'mobile.markdown.link.copy_url', defaultMessage: 'Copy URL'}), + onPress: this.handleCopyURL + }; + } + + this.props.onLongPress(action); + } + + handleCopyURL = () => { + Clipboard.setString(this.props.href); + } + render() { const children = Config.ExperimentalNormalizeMarkdownLinks ? this.parseChildren() : this.props.children; return ( {children} ); } } + +export default injectIntl(MarkdownLink); \ No newline at end of file diff --git a/app/components/options_context/options_context.android.js b/app/components/options_context/options_context.android.js index 0d84ddd5d..1334e541b 100644 --- a/app/components/options_context/options_context.android.js +++ b/app/components/options_context/options_context.android.js @@ -20,16 +20,22 @@ export default class OptionsContext extends PureComponent { cancelText: 'Cancel' }; - show = () => { + show = (additionalAction) => { const {actions, cancelText} = this.props; - if (actions.length) { - const actionsText = actions.map((a) => a.text); + const nextActions = [...actions]; + if (additionalAction && !additionalAction.nativeEvent && additionalAction.text) { + const copyPostIndex = nextActions.findIndex((action) => action.copyPost); + nextActions.splice(copyPostIndex + 1, 0, additionalAction); + } + + if (nextActions.length) { + const actionsText = nextActions.map((a) => a.text); RNBottomSheet.showBottomSheetWithOptions({ options: [...actionsText, cancelText], - cancelButtonIndex: actions.length + cancelButtonIndex: nextActions.length }, (value) => { - if (value !== actions.length) { - const selectedOption = actions[value]; + if (value !== nextActions.length) { + const selectedOption = nextActions[value]; if (selectedOption && selectedOption.onPress) { selectedOption.onPress(); } diff --git a/app/components/options_context/options_context.ios.js b/app/components/options_context/options_context.ios.js index 17b441850..fb5a29bc9 100644 --- a/app/components/options_context/options_context.ios.js +++ b/app/components/options_context/options_context.ios.js @@ -14,9 +14,18 @@ export default class OptionsContext extends PureComponent { }; static defaultProps = { - actions: [] + actions: [], + additionalActions: [] }; + constructor(props) { + super(props); + + this.state = { + actions: props.actions + }; + } + handleHideUnderlay = () => { if (!this.isShowing) { this.props.toggleSelected(false, false); @@ -39,9 +48,22 @@ export default class OptionsContext extends PureComponent { hide = () => { this.refs.toolTip.hideMenu(); + this.setState({ + actions: this.props.actions + }); }; - show = () => { + show = (additionalAction) => { + const nextActions = [...this.props.actions]; + if (additionalAction && additionalAction.text && !additionalAction.nativeEvent) { + const copyPostIndex = nextActions.findIndex((action) => action.copyPost); + nextActions.splice(copyPostIndex + 1, 0, additionalAction); + } + + this.setState({ + actions: nextActions + }); + this.refs.toolTip.showMenu(); }; @@ -56,7 +78,7 @@ export default class OptionsContext extends PureComponent { onHideUnderlay={this.handleHideUnderlay} onShowUnderlay={this.handleShowUnderlay} ref='toolTip' - actions={this.props.actions} + actions={this.state.actions} arrowDirection='down' longPress={true} onPress={this.handlePress} diff --git a/app/components/post/post.js b/app/components/post/post.js index f18082daf..2c312286a 100644 --- a/app/components/post/post.js +++ b/app/components/post/post.js @@ -5,6 +5,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { Alert, + Clipboard, View, ViewPropTypes } from 'react-native'; @@ -50,6 +51,7 @@ class Post extends PureComponent { isSearchResult: PropTypes.bool, commentedOnPost: PropTypes.object, license: PropTypes.object.isRequired, + managedConfig: PropTypes.object.isRequired, navigator: PropTypes.object, roles: PropTypes.string, shouldRenderReplyButton: PropTypes.bool, @@ -317,6 +319,15 @@ class Post extends PureComponent { } }; + handleCopyText = (text) => { + let textToCopy = this.props.post.message; + if (typeof text === 'string') { + textToCopy = text; + } + + Clipboard.setString(textToCopy); + } + render() { const { commentedOnPost, @@ -327,7 +338,8 @@ class Post extends PureComponent { renderReplies, shouldRenderReplyButton, showFullDate, - theme + theme, + managedConfig } = this.props; if (!post) { @@ -369,6 +381,7 @@ class Post extends PureComponent { isSearchResult={isSearchResult} navigator={this.props.navigator} onAddReaction={this.handleAddReaction} + onCopyText={this.handleCopyText} onFailedPostPress={this.handleFailedPostPress} onPostDelete={this.handlePostDelete} onPostEdit={this.handlePostEdit} @@ -376,6 +389,7 @@ class Post extends PureComponent { postId={post.id} renderReplyBar={commentedOnPost ? this.renderReplyBar : emptyFunction} toggleSelected={this.toggleSelected} + managedConfig={managedConfig} /> diff --git a/app/components/post_body/post_body.js b/app/components/post_body/post_body.js index 769f55100..5a454b4ed 100644 --- a/app/components/post_body/post_body.js +++ b/app/components/post_body/post_body.js @@ -42,9 +42,11 @@ class PostBody extends PureComponent { isPostEphemeral: PropTypes.bool, isSearchResult: PropTypes.bool, isSystemMessage: PropTypes.bool, + managedConfig: PropTypes.object, message: PropTypes.string, navigator: PropTypes.object.isRequired, onAddReaction: PropTypes.func, + onCopyText: PropTypes.func, onFailedPostPress: PropTypes.func, onPostDelete: PropTypes.func, onPostEdit: PropTypes.func, @@ -59,6 +61,7 @@ class PostBody extends PureComponent { static defaultProps = { fileIds: [], onAddReaction: emptyFunction, + onCopyText: emptyFunction, onFailedPostPress: emptyFunction, onPostDelete: emptyFunction, onPostEdit: emptyFunction, @@ -91,9 +94,9 @@ class PostBody extends PureComponent { actions.unflagPost(postId); }; - showOptionsContext = () => { + showOptionsContext = (additionalAction) => { if (this.refs.options) { - this.refs.options.show(); + this.refs.options.show(additionalAction); } }; @@ -125,7 +128,7 @@ class PostBody extends PureComponent { return attachments; } - render() { + render() { // eslint-disable-line complexity const { canDelete, canEdit, @@ -138,6 +141,7 @@ class PostBody extends PureComponent { isSearchResult, isSystemMessage, intl, + managedConfig, message, navigator, onFailedPostPress, @@ -160,6 +164,19 @@ class PostBody extends PureComponent { // we should check for the user roles and permissions if (!isPendingOrFailedPost && !isSearchResult && !isSystemMessage && !isPostEphemeral) { + actions.push({ + text: formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}), + onPress: this.props.onAddReaction + }); + + if (managedConfig.copyAndPasteProtection !== 'true') { + actions.push({ + text: formatMessage({id: 'mobile.post_info.copy_post', defaultMessage: 'Copy Post'}), + onPress: this.props.onCopyText, + copyPost: true + }); + } + if (isFlagged) { actions.push({ text: formatMessage({id: 'post_info.mobile.unflag', defaultMessage: 'Unflag'}), @@ -179,11 +196,6 @@ class PostBody extends PureComponent { if (canDelete && !hasBeenDeleted) { actions.push({text: formatMessage({id: 'post_info.del', defaultMessage: 'Delete'}), onPress: onPostDelete}); } - - actions.push({ - text: formatMessage({id: 'mobile.post_info.add_reaction', defaultMessage: 'Add Reaction'}), - onPress: this.props.onAddReaction - }); } let body; @@ -265,6 +277,7 @@ class PostBody extends PureComponent { message={message} postProps={postProps} textStyles={textStyles} + onLongPress={this.showOptionsContext} /> {this.renderFileAttachments()} {hasReactions && } diff --git a/app/components/post_body_additional_content/post_body_additional_content.js b/app/components/post_body_additional_content/post_body_additional_content.js index 79e228a79..39e277724 100644 --- a/app/components/post_body_additional_content/post_body_additional_content.js +++ b/app/components/post_body_additional_content/post_body_additional_content.js @@ -11,14 +11,15 @@ import { TouchableWithoutFeedback, View } from 'react-native'; - import {YouTubeStandaloneAndroid, YouTubeStandaloneIOS} from 'react-native-youtube'; import youTubeVideoId from 'youtube-video-id'; + import youtubePlayIcon from 'assets/images/icons/youtube-play-icon.png'; import PostAttachmentOpenGraph from 'app/components/post_attachment_opengraph'; import SlackAttachments from 'app/components/slack_attachments'; import CustomPropTypes from 'app/constants/custom_prop_types'; +import {emptyFunction} from 'app/utils/general'; import {isImageLink, isYoutubeLink} from 'app/utils/url'; const MAX_IMAGE_HEIGHT = 150; @@ -33,6 +34,7 @@ export default class PostBodyAdditionalContent extends PureComponent { link: PropTypes.string, message: PropTypes.string.isRequired, navigator: PropTypes.object.isRequired, + onLongPress: PropTypes.func, openGraphData: PropTypes.object, postProps: PropTypes.object.isRequired, showLinkPreviews: PropTypes.bool.isRequired, @@ -40,6 +42,10 @@ export default class PostBodyAdditionalContent extends PureComponent { theme: PropTypes.object.isRequired }; + static defaultProps = { + onLongPress: emptyFunction + } + constructor(props) { super(props); @@ -163,6 +169,7 @@ export default class PostBodyAdditionalContent extends PureComponent { navigator={navigator} textStyles={textStyles} theme={theme} + onLongPress={this.props.onLongPress} /> ); } diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index d8a9a928c..c48a94f4b 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -12,6 +12,7 @@ import ChannelIntro from 'app/components/channel_intro'; import FlatList from 'app/components/inverted_flat_list'; import Post from 'app/components/post'; import {DATE_LINE, START_OF_NEW_MESSAGES} from 'app/selectors/post_list'; +import mattermostManaged from 'app/mattermost_managed'; import DateHeader from './date_header'; import LoadMorePosts from './load_more_posts'; @@ -43,6 +44,22 @@ export default class PostList extends PureComponent { loadMore: () => true }; + constructor(props) { + super(props); + + this.state = { + managedConfig: {} + }; + } + + componentWillMount() { + mattermostManaged.addEventListener('change', this.setManagedConfig); + } + + componentDidMount() { + this.setManagedConfig(); + } + componentDidUpdate(prevProps) { if (prevProps.channelId !== this.props.channelId && this.refs.list) { // When switching channels make sure we start from the bottom @@ -50,6 +67,17 @@ export default class PostList extends PureComponent { } } + setManagedConfig = async (config) => { + let nextConfig = config; + if (!nextConfig) { + nextConfig = await mattermostManaged.getLocalConfig(); + } + + this.setState({ + managedConfig: nextConfig + }); + } + getItem = (data, index) => data[index]; getItemCount = (data) => data.length; @@ -115,6 +143,7 @@ export default class PostList extends PureComponent { renderReplies, shouldRenderReplyButton } = this.props; + const {managedConfig} = this.state; return ( ); }; diff --git a/app/components/slack_attachments/index.js b/app/components/slack_attachments/index.js index 6797bca40..a8b97e38e 100644 --- a/app/components/slack_attachments/index.js +++ b/app/components/slack_attachments/index.js @@ -16,11 +16,12 @@ export default class SlackAttachments extends PureComponent { blockStyles: PropTypes.object, textStyles: PropTypes.object, navigator: PropTypes.object.isRequired, - theme: PropTypes.object + theme: PropTypes.object, + onLongPress: PropTypes.func.isRequired }; render() { - const {attachments, baseTextStyle, blockStyles, navigator, textStyles, theme} = this.props; + const {attachments, baseTextStyle, blockStyles, navigator, textStyles, theme, onLongPress} = this.props; const content = []; attachments.forEach((attachment, i) => { @@ -33,6 +34,7 @@ export default class SlackAttachments extends PureComponent { textStyles={textStyles} navigator={navigator} theme={theme} + onLongPress={onLongPress} /> ); }); diff --git a/app/components/slack_attachments/slack_attachment.js b/app/components/slack_attachments/slack_attachment.js index 08df02e80..6b923ab67 100644 --- a/app/components/slack_attachments/slack_attachment.js +++ b/app/components/slack_attachments/slack_attachment.js @@ -22,7 +22,8 @@ export default class SlackAttachment extends PureComponent { blockStyles: PropTypes.object, navigator: PropTypes.object.isRequired, textStyles: PropTypes.object, - theme: PropTypes.object + theme: PropTypes.object, + onLongPress: PropTypes.func.isRequired }; constructor(props) { @@ -117,6 +118,7 @@ export default class SlackAttachment extends PureComponent { blockStyles={blockStyles} value={(field.value || '')} navigator={navigator} + onLongPress={this.props.onLongPress} /> @@ -163,7 +165,7 @@ export default class SlackAttachment extends PureComponent { this.setState({collapsed, text}); }; - render() { + render() { // eslint-disable-line complexity const { attachment, baseTextStyle, @@ -185,6 +187,7 @@ export default class SlackAttachment extends PureComponent { blockStyles={blockStyles} value={attachment.pretext} navigator={navigator} + onLongPress={this.props.onLongPress} /> ); @@ -293,6 +296,7 @@ export default class SlackAttachment extends PureComponent { blockStyles={blockStyles} value={this.state.text} navigator={navigator} + onLongPress={this.props.onLongPress} /> {moreLess} diff --git a/app/mattermost_managed/mattermost-managed.android.js b/app/mattermost_managed/mattermost-managed.android.js index 5ad1faab7..ad5e6b032 100644 --- a/app/mattermost_managed/mattermost-managed.android.js +++ b/app/mattermost_managed/mattermost-managed.android.js @@ -7,9 +7,12 @@ import JailMonkey from 'jail-monkey'; const {MattermostManaged} = NativeModules; +let localConfig; + export default { addEventListener: (name, callback) => { DeviceEventEmitter.addListener(name, (config) => { + localConfig = config; if (callback && typeof callback === 'function') { callback(config); } @@ -19,6 +22,17 @@ export default { authenticate: LocalAuth.authenticate, blurAppScreen: MattermostManaged.blurAppScreen, getConfig: MattermostManaged.getConfig, + getLocalConfig: async () => { + if (!localConfig) { + try { + localConfig = await MattermostManaged.getConfig(); + } catch (error) { + // do nothing... + } + } + + return localConfig || {}; + }, isDeviceSecure: async () => { try { return await LocalAuth.isDeviceSecure(); diff --git a/app/mattermost_managed/mattermost-managed.ios.js b/app/mattermost_managed/mattermost-managed.ios.js index 35429dd73..0fd9643ae 100644 --- a/app/mattermost_managed/mattermost-managed.ios.js +++ b/app/mattermost_managed/mattermost-managed.ios.js @@ -7,10 +7,12 @@ import JailMonkey from 'jail-monkey'; const {BlurAppScreen, MattermostManaged} = NativeModules; const listeners = []; +let localConfig; export default { addEventListener: (name, callback) => { const listener = DeviceEventEmitter.addListener(name, (config) => { + localConfig = config; if (callback && typeof callback === 'function') { callback(config); } @@ -26,6 +28,17 @@ export default { authenticate: LocalAuth.authenticate, blurAppScreen: BlurAppScreen.enabled, getConfig: MattermostManaged.getConfig, + getLocalConfig: async () => { + if (!localConfig) { + try { + localConfig = await MattermostManaged.getConfig(); + } catch (error) { + // do nothing... + } + } + + return localConfig || {}; + }, isDeviceSecure: async () => { try { return await LocalAuth.isDeviceSecure(); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 16f2aa130..1eefaccad 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1970,7 +1970,10 @@ "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.copy_code": "Copy Code", + "mobile.markdown.link.copy_url": "Copy URL", "mobile.markdown.code.plusMoreLines": "+{count, number} more lines", + "mobile.mention.copy_mention": "Copy Mention", "mobile.more_dms.start": "Start", "mobile.more_dms.title": "New Conversation", "mobile.notice_mobile_link": "mobile apps", @@ -2019,6 +2022,7 @@ "mobile.post.failed_title": "Unable to send your message", "mobile.post.retry": "Refresh", "mobile.post_info.add_reaction": "Add Reaction", + "mobile.post_info.copy_post": "Copy Post", "mobile.request.invalid_response": "Received invalid response from the server.", "mobile.retry_message": "Refreshing messages failed. Pull up to try again.", "mobile.routes.channelInfo": "Info",