[MM-13381] Added reply to post options (#2665)

* [MM-13381] Added reply to post options

* [MM-13381] Fixes for adding reply to post options

* [MM-13381] Fixed logic for adding reply to post options

* [MM-13381] Fixed canReply logic for post options
This commit is contained in:
Courtney Pattison 2019-03-25 13:29:46 -07:00 committed by Miguel Alatzar
parent cd875ac3a8
commit c3a1e3561e
12 changed files with 237 additions and 88 deletions

View file

@ -63,6 +63,7 @@ export default class Post extends PureComponent {
skipFlaggedHeader: PropTypes.bool,
skipPinnedHeader: PropTypes.bool,
isCommentMention: PropTypes.bool,
location: PropTypes.string,
};
static defaultProps = {
@ -255,6 +256,7 @@ export default class Post extends PureComponent {
highlightPinnedOrFlagged,
skipFlaggedHeader,
skipPinnedHeader,
location,
} = this.props;
if (!post) {
@ -348,6 +350,7 @@ export default class Post extends PureComponent {
isReplyPost={isReplyPost}
showAddReaction={showAddReaction}
showLongPost={showLongPost}
location={location}
/>
</View>
</View>

View file

@ -66,6 +66,7 @@ export default class PostBody extends PureComponent {
isEmojiOnly: PropTypes.bool.isRequired,
shouldRenderJumboEmoji: PropTypes.bool.isRequired,
theme: PropTypes.object,
location: PropTypes.string,
};
static defaultProps = {
@ -149,6 +150,7 @@ export default class PostBody extends PureComponent {
navigator,
postId,
showAddReaction,
location,
} = this.props;
if (isSystemMessage && (!canDelete || hasBeenDeleted)) {
@ -178,6 +180,7 @@ export default class PostBody extends PureComponent {
postId,
managedConfig,
showAddReaction,
location,
},
};

View file

@ -171,6 +171,7 @@ export default class PostListBase extends PureComponent {
onPostPress,
renderReplies,
shouldRenderReplyButton,
location,
} = this.props;
const {managedConfig} = this.state;
@ -190,6 +191,7 @@ export default class PostListBase extends PureComponent {
onPress={onPostPress}
navigator={navigator}
managedConfig={managedConfig}
location={location}
/>
);
};

View file

@ -1,6 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostOptions should match snapshot, no option for system message to user who doesn't have the permission to delete 1`] = `null`;
exports[`PostOptions should match snapshot, no option for system message to user who doesn't have the permission to delete 1`] = `
<View
style={
Object {
"flex": 1,
}
}
>
<Connect(SlideUpPanel)
allowStayMiddle={false}
initialPosition={274}
marginFromTop={326}
onRequestClose={[Function]}
>
<PostOption
icon="edit"
onPress={[Function]}
text="Edit"
/>
<PostOption
icon="reply"
onPress={[Function]}
text="Reply"
/>
<PostOption
icon="pin"
onPress={[Function]}
text="Pin to Channel"
/>
<PostOption
icon="emoji"
onPress={[Function]}
text="Add Reaction"
/>
</Connect(SlideUpPanel)>
</View>
`;
exports[`PostOptions should match snapshot, showing Delete option only for system message to user who has permission to delete 1`] = `
<View
@ -12,10 +48,30 @@ exports[`PostOptions should match snapshot, showing Delete option only for syste
>
<Connect(SlideUpPanel)
allowStayMiddle={false}
initialPosition={124}
marginFromTop={476}
initialPosition={324}
marginFromTop={276}
onRequestClose={[Function]}
>
<PostOption
icon="edit"
onPress={[Function]}
text="Edit"
/>
<PostOption
icon="reply"
onPress={[Function]}
text="Reply"
/>
<PostOption
icon="pin"
onPress={[Function]}
text="Pin to Channel"
/>
<PostOption
icon="emoji"
onPress={[Function]}
text="Add Reaction"
/>
<PostOption
destructive={true}
icon="trash"
@ -36,8 +92,8 @@ exports[`PostOptions should match snapshot, showing all possible options 1`] = `
>
<Connect(SlideUpPanel)
allowStayMiddle={false}
initialPosition={424}
marginFromTop={176}
initialPosition={324}
marginFromTop={276}
onRequestClose={[Function]}
>
<PostOption
@ -46,9 +102,9 @@ exports[`PostOptions should match snapshot, showing all possible options 1`] = `
text="Edit"
/>
<PostOption
icon="flag"
icon="reply"
onPress={[Function]}
text="Flag"
text="Reply"
/>
<PostOption
icon="pin"
@ -60,16 +116,6 @@ exports[`PostOptions should match snapshot, showing all possible options 1`] = `
onPress={[Function]}
text="Add Reaction"
/>
<PostOption
icon="link"
onPress={[Function]}
text="Copy Permalink"
/>
<PostOption
icon="copy"
onPress={[Function]}
text="Copy Text"
/>
<PostOption
destructive={true}
icon="trash"

View file

@ -11,6 +11,7 @@ import {
unflagPost,
unpinPost,
removePost,
selectPost,
} from 'mattermost-redux/actions/posts';
import {General, Permissions} from 'mattermost-redux/constants';
import {getChannel, getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels';
@ -22,6 +23,8 @@ import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'
import {getCurrentTeamId, getCurrentTeamUrl} from 'mattermost-redux/selectors/entities/teams';
import {canEditPost} from 'mattermost-redux/utils/post_utils';
import {loadThreadIfNecessary} from 'app/actions/views/channel';
import {THREAD} from 'app/constants/screen';
import {addReaction} from 'app/actions/views/emoji';
import {getDimensions} from 'app/selectors/device';
@ -39,9 +42,13 @@ function mapStateToProps(state, ownProps) {
const channelIsArchived = channel.delete_at !== 0;
let canAddReaction = true;
let canReply = true;
let canCopyPermalink = true;
let canCopyText = false;
let canEdit = false;
let canEditUntil = -1;
let {canDelete} = ownProps;
let canFlag = true;
let canPin = true;
if (hasNewPermissions(state)) {
@ -52,8 +59,13 @@ function mapStateToProps(state, ownProps) {
});
}
if (channelIsArchived) {
if (ownProps.location === THREAD) {
canReply = false;
}
if (channelIsArchived || ownProps.channelIsReadOnly) {
canAddReaction = false;
canReply = false;
canDelete = false;
canPin = false;
} else {
@ -65,12 +77,39 @@ function mapStateToProps(state, ownProps) {
}
}
if (ownProps.channelIsReadOnly) {
canFlag = false;
}
if (ownProps.isSystemMessage) {
canAddReaction = false;
canReply = false;
canCopyPermalink = false;
canEdit = false;
canPin = false;
}
if (ownProps.hasBeenDeleted) {
canDelete = false;
}
if (!ownProps.showAddReaction) {
canAddReaction = false;
}
if (!ownProps.isSystemMessage && ownProps.managedConfig.copyAndPasteProtection !== 'true' && post.message) {
canCopyText = true;
}
return {
...getDimensions(state),
canAddReaction,
canReply,
canCopyPermalink,
canCopyText,
canEdit,
canEditUntil,
canDelete,
canFlag,
canPin,
currentTeamUrl: getCurrentTeamUrl(state),
isMyPost: currentUserId === post.user_id,
@ -89,6 +128,8 @@ function mapDispatchToProps(dispatch) {
removePost,
unflagPost,
unpinPost,
selectPost,
loadThreadIfNecessary,
}, dispatch),
};
}

View file

@ -20,6 +20,7 @@ import flag from 'assets/images/post_menu/flag.png';
import link from 'assets/images/post_menu/link.png';
import pin from 'assets/images/post_menu/pin.png';
import trash from 'assets/images/post_menu/trash.png';
import reply from 'assets/images/post_menu/reply.png';
const icons = {
copy,
@ -29,6 +30,7 @@ const icons = {
link,
pin,
trash,
reply,
};
export default class PostOption extends PureComponent {

View file

@ -3,7 +3,7 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Alert, Clipboard, StyleSheet, View} from 'react-native';
import {Alert, Clipboard, Platform, StyleSheet, View} from 'react-native';
import {intlShape} from 'react-intl';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
@ -23,23 +23,24 @@ export default class PostOptions extends PureComponent {
removePost: PropTypes.func.isRequired,
unflagPost: PropTypes.func.isRequired,
unpinPost: PropTypes.func.isRequired,
selectPost: PropTypes.func.isRequired,
loadThreadIfNecessary: PropTypes.func.isRequired,
}).isRequired,
canAddReaction: PropTypes.bool,
canReply: PropTypes.bool,
canCopyPermalink: PropTypes.bool,
canCopyText: PropTypes.bool,
canDelete: PropTypes.bool,
canFlag: PropTypes.bool,
canPin: PropTypes.bool,
canEdit: PropTypes.bool,
canEditUntil: PropTypes.number.isRequired,
channelIsReadOnly: PropTypes.bool,
currentTeamUrl: PropTypes.string.isRequired,
deviceHeight: PropTypes.number.isRequired,
hasBeenDeleted: PropTypes.bool,
isFlagged: PropTypes.bool,
isMyPost: PropTypes.bool,
isSystemMessage: PropTypes.bool,
managedConfig: PropTypes.object.isRequired,
navigator: PropTypes.object.isRequired,
post: PropTypes.object.isRequired,
showAddReaction: PropTypes.bool,
theme: PropTypes.object.isRequired,
};
@ -63,9 +64,9 @@ export default class PostOptions extends PureComponent {
getAddReactionOption = () => {
const {formatMessage} = this.context.intl;
const {canAddReaction, channelIsReadOnly, isSystemMessage, showAddReaction} = this.props;
const {canAddReaction} = this.props;
if (!isSystemMessage && showAddReaction && canAddReaction && !channelIsReadOnly) {
if (canAddReaction) {
return (
<PostOption
key='reaction'
@ -79,28 +80,47 @@ export default class PostOptions extends PureComponent {
return null;
};
getCopyPermalink = () => {
if (this.props.isSystemMessage) {
return null;
getReplyOption = () => {
const {formatMessage} = this.context.intl;
const {canReply} = this.props;
if (canReply) {
return (
<PostOption
key='reply'
icon='reply'
text={formatMessage({id: 'mobile.post_info.reply', defaultMessage: 'Reply'})}
onPress={this.handleReply}
/>
);
}
const {formatMessage} = this.context.intl;
return null;
}
return (
<PostOption
key='permalink'
icon='link'
text={formatMessage({id: 'get_post_link_modal.title', defaultMessage: 'Copy Permalink'})}
onPress={this.handleCopyPermalink}
/>
);
getCopyPermalink = () => {
const {formatMessage} = this.context.intl;
const {canCopyPermalink} = this.props;
if (canCopyPermalink) {
return (
<PostOption
key='permalink'
icon='link'
text={formatMessage({id: 'get_post_link_modal.title', defaultMessage: 'Copy Permalink'})}
onPress={this.handleCopyPermalink}
/>
);
}
return null;
};
getCopyText = () => {
const {formatMessage} = this.context.intl;
const {isSystemMessage, managedConfig, post} = this.props;
const {canCopyText} = this.props;
if (!isSystemMessage && managedConfig.copyAndPasteProtection !== 'true' && post.message) {
if (canCopyText) {
return (
<PostOption
key='copy'
@ -116,9 +136,9 @@ export default class PostOptions extends PureComponent {
getDeleteOption = () => {
const {formatMessage} = this.context.intl;
const {canDelete, hasBeenDeleted} = this.props;
const {canDelete} = this.props;
if (canDelete && !hasBeenDeleted) {
if (canDelete) {
return (
<PostOption
destructive={true}
@ -135,9 +155,9 @@ export default class PostOptions extends PureComponent {
getEditOption = () => {
const {formatMessage} = this.context.intl;
const {canEdit, canEditUntil, isSystemMessage} = this.props;
const {canEdit, canEditUntil} = this.props;
if (!isSystemMessage && canEdit && (canEditUntil === -1 || canEditUntil > Date.now())) {
if (canEdit && (canEditUntil === -1 || canEditUntil > Date.now())) {
return (
<PostOption
key='edit'
@ -153,9 +173,9 @@ export default class PostOptions extends PureComponent {
getFlagOption = () => {
const {formatMessage} = this.context.intl;
const {channelIsReadOnly, isFlagged, isSystemMessage} = this.props;
const {canFlag, isFlagged} = this.props;
if (isSystemMessage || channelIsReadOnly) {
if (!canFlag) {
return null;
}
@ -182,9 +202,9 @@ export default class PostOptions extends PureComponent {
getPinOption = () => {
const {formatMessage} = this.context.intl;
const {channelIsReadOnly, isSystemMessage, post} = this.props;
const {canPin, post} = this.props;
if (isSystemMessage || channelIsReadOnly) {
if (!canPin) {
return null;
}
@ -212,51 +232,37 @@ export default class PostOptions extends PureComponent {
getMyPostOptions = () => {
const actions = [
this.getEditOption(),
this.getReplyOption(),
this.getFlagOption(),
this.getPinOption(),
this.getAddReactionOption(),
this.getCopyPermalink(),
this.getCopyText(),
this.getDeleteOption(),
];
const {canDelete, canPin} = this.props;
if (canPin) {
actions.splice(2, 0, this.getPinOption());
}
if (canDelete) {
actions.push(this.getDeleteOption());
}
return actions.filter((a) => a !== null);
};
getOthersPostOptions = () => {
const actions = [
this.getReplyOption(),
this.getFlagOption(),
this.getAddReactionOption(),
this.getPinOption(),
this.getCopyPermalink(),
this.getCopyText(),
this.getEditOption(),
this.getDeleteOption(),
];
const {canDelete, canPin} = this.props;
if (canPin) {
actions.splice(2, 0, this.getPinOption());
}
if (canDelete) {
actions.push(this.getDeleteOption());
}
return actions.filter((a) => a !== null);
};
getPostOptions = () => {
const {isMyPost} = this.props;
if (isMyPost) {
return this.getMyPostOptions();
}
return this.getOthersPostOptions();
return isMyPost ? this.getMyPostOptions() : this.getOthersPostOptions();
};
handleAddReaction = () => {
@ -285,6 +291,37 @@ export default class PostOptions extends PureComponent {
});
};
handleReply = () => {
const {actions, post, navigator, theme} = this.props;
const rootId = (post.root_id || post.id);
const channelId = post.channel_id;
actions.loadThreadIfNecessary(rootId, channelId);
actions.selectPost(rootId);
const options = {
screen: 'Thread',
animated: true,
backButtonTitle: '',
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
navBarBackgroundColor: theme.sidebarHeaderBg,
navBarButtonColor: theme.sidebarHeaderTextColor,
screenBackgroundColor: theme.centerChannelBg,
},
passProps: {
channelId,
rootId,
},
};
if (Platform.OS === 'android') {
navigator.showModal(options);
} else {
navigator.push(options);
}
}
handleAddReactionToPost = (emoji) => {
const {actions, post} = this.props;

View file

@ -14,6 +14,7 @@ describe('PostOptions', () => {
const navigator = {
showModal: jest.fn(),
dismissModal: jest.fn(),
push: jest.fn(),
};
const actions = {
@ -24,16 +25,22 @@ describe('PostOptions', () => {
removePost: jest.fn(),
unflagPost: jest.fn(),
unpinPost: jest.fn(),
selectPost: jest.fn(),
loadThreadIfNecessary: jest.fn(),
};
const post = {
root_id: 'root_id',
id: 'post_id',
message: 'message',
is_pinned: false,
channel_id: 'channel_id',
};
const baseProps = {
actions,
canAddReaction: true,
canReply: true,
canDelete: true,
canPin: true,
canEdit: true,
@ -52,37 +59,44 @@ describe('PostOptions', () => {
theme: Preferences.THEMES.default,
};
test('should match snapshot, showing all possible options', () => {
const wrapper = shallow(
<PostOptions {...baseProps}/>,
{context: {intl: {formatMessage: jest.fn((format) => format.defaultMessage)}}},
function getWrapper(props = {}) {
return shallow(
<PostOptions
{...baseProps}
{...props}
/>,
{context: {intl: {formatMessage: ({defaultMessage}) => defaultMessage}}}
);
}
test('should match snapshot, showing all possible options', () => {
const wrapper = getWrapper();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot, showing Delete option only for system message to user who has permission to delete', () => {
const wrapper = shallow(
<PostOptions
{...baseProps}
isSystemMessage={true}
/>,
{context: {intl: {formatMessage: jest.fn((format) => format.defaultMessage)}}},
);
const wrapper = getWrapper({isSystemMessage: true});
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot, no option for system message to user who doesn\'t have the permission to delete', () => {
const wrapper = shallow(
<PostOptions
{...baseProps}
isSystemMessage={true}
canDelete={false}
/>,
{context: {intl: {formatMessage: jest.fn((format) => format.defaultMessage)}}},
);
const wrapper = getWrapper({isSystemMessage: true, canDelete: false});
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should load thread', () => {
const wrapper = getWrapper();
wrapper.findWhere((node) => node.key() === 'reply').simulate('press');
expect(actions.loadThreadIfNecessary).toBeCalled();
});
test('should not show reply option', () => {
const wrapper = getWrapper({canReply: false});
expect(wrapper.findWhere((node) => node.key() === 'reply')).toMatchObject({});
});
});

View file

@ -339,6 +339,7 @@
"mobile.post_info.flag": "Flag",
"mobile.post_info.unflag": "Unflag",
"mobile.post_info.pin": "Pin to Channel",
"mobile.post_info.reply": "Reply",
"mobile.post_info.unpin": "Unpin from Channel",
"mobile.post_pre_header.flagged": "Flagged",
"mobile.post_pre_header.pinned_flagged": "Pinned and Flagged",

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB