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
This commit is contained in:
Chris Duarte 2017-10-31 09:42:56 -07:00 committed by enahum
parent 3fdfe92800
commit 2f5201bac9
15 changed files with 246 additions and 35 deletions

View file

@ -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 {
<Text
style={textStyle}
onPress={isSearchResult ? onPostPress : this.goToUserProfile}
onLongPress={this.handleLongPress}
>
<Text style={mentionStyle}>
{'@' + username}
@ -109,5 +140,3 @@ class AtMention extends React.PureComponent {
);
}
}
export default injectIntl(AtMention);

View file

@ -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}
/>
);
}

View file

@ -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 (
<TouchableOpacity onPress={this.handlePress}>
<TouchableOpacity
onPress={this.handlePress}
onLongPress={this.handleLongPress}
>
<View style={style.container}>
<View style={style.lineNumbers}>
<Text style={style.lineNumbersText}>

View file

@ -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 (
<Text
onPress={this.handlePress}
onLongPress={this.props.onLongPress}
onLongPress={this.handleLongPress}
>
{children}
</Text>
);
}
}
export default injectIntl(MarkdownLink);

View file

@ -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();
}

View file

@ -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}

View file

@ -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}
/>
</View>
</View>

View file

@ -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 && <Reactions postId={postId}/>}

View file

@ -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}
/>
);
}

View file

@ -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 (
<Post
@ -127,6 +156,7 @@ export default class PostList extends PureComponent {
shouldRenderReplyButton={shouldRenderReplyButton}
onPress={onPostPress}
navigator={navigator}
managedConfig={managedConfig}
/>
);
};

View file

@ -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}
/>
);
});

View file

@ -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}
/>
</View>
</View>
@ -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}
/>
</View>
);
@ -293,6 +296,7 @@ export default class SlackAttachment extends PureComponent {
blockStyles={blockStyles}
value={this.state.text}
navigator={navigator}
onLongPress={this.props.onLongPress}
/>
{moreLess}
</View>

View file

@ -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();

View file

@ -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();

View file

@ -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",