diff --git a/app/actions/views/post.js b/app/actions/views/post.js
new file mode 100644
index 000000000..27971cd60
--- /dev/null
+++ b/app/actions/views/post.js
@@ -0,0 +1,39 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {Posts} from 'mattermost-redux/constants';
+import {PostTypes} from 'mattermost-redux/action_types';
+
+import {generateId} from 'app/utils/file';
+
+export function sendAddToChannelEphemeralPost(user, addedUsername, message, channelId, postRootId = '') {
+ return async (dispatch) => {
+ const timestamp = Date.now();
+ const post = {
+ id: generateId(),
+ user_id: user.id,
+ channel_id: channelId,
+ message,
+ type: Posts.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL,
+ create_at: timestamp,
+ update_at: timestamp,
+ root_id: postRootId,
+ parent_id: postRootId,
+ props: {
+ username: user.username,
+ addedUsername,
+ },
+ };
+
+ dispatch({
+ type: PostTypes.RECEIVED_POSTS,
+ data: {
+ order: [],
+ posts: {
+ [post.id]: post,
+ },
+ },
+ channelId,
+ });
+ };
+}
diff --git a/app/components/post_add_channel_member/index.js b/app/components/post_add_channel_member/index.js
new file mode 100644
index 000000000..06982d656
--- /dev/null
+++ b/app/components/post_add_channel_member/index.js
@@ -0,0 +1,45 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {connect} from 'react-redux';
+import {bindActionCreators} from 'redux';
+
+import {addChannelMember} from 'mattermost-redux/actions/channels';
+import {removePost} from 'mattermost-redux/actions/posts';
+
+import {getPost} from 'mattermost-redux/selectors/entities/posts';
+import {getChannel} from 'mattermost-redux/selectors/entities/channels';
+import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
+
+import {sendAddToChannelEphemeralPost} from 'app/actions/views/post';
+
+import PostAddChannelMember from './post_add_channel_member';
+
+function mapStateToProps(state, ownProps) {
+ const post = getPost(state, ownProps.postId) || {};
+ let channelType = '';
+ if (post && post.channel_id) {
+ const channel = getChannel(state, post.channel_id);
+ if (channel && channel.type) {
+ channelType = channel.type;
+ }
+ }
+
+ return {
+ channelType,
+ currentUser: getCurrentUser(state),
+ post,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators({
+ addChannelMember,
+ removePost,
+ sendAddToChannelEphemeralPost,
+ }, dispatch),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(PostAddChannelMember);
diff --git a/app/components/post_add_channel_member/post_add_channel_member.js b/app/components/post_add_channel_member/post_add_channel_member.js
new file mode 100644
index 000000000..8a16a3026
--- /dev/null
+++ b/app/components/post_add_channel_member/post_add_channel_member.js
@@ -0,0 +1,189 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import PropTypes from 'prop-types';
+import React from 'react';
+import {intlShape} from 'react-intl';
+
+import {Text} from 'react-native';
+
+import {General} from 'mattermost-redux/constants';
+
+import {concatStyles} from 'app/utils/theme';
+
+import AtMention from 'app/components/at_mention';
+import FormattedText from 'app/components/formatted_text';
+
+export default class PostAddChannelMember extends React.PureComponent {
+ static propTypes = {
+ actions: PropTypes.shape({
+ addChannelMember: PropTypes.func.isRequired,
+ removePost: PropTypes.func.isRequired,
+ sendAddToChannelEphemeralPost: PropTypes.func.isRequired,
+ }).isRequired,
+ currentUser: PropTypes.object.isRequired,
+ channelType: PropTypes.string,
+ post: PropTypes.object.isRequired,
+ postId: PropTypes.string.isRequired,
+ userIds: PropTypes.array.isRequired,
+ usernames: PropTypes.array.isRequired,
+ navigator: PropTypes.object.isRequired,
+ onLongPress: PropTypes.func,
+ onPostPress: PropTypes.func,
+ textStyles: PropTypes.object,
+ };
+
+ static contextTypes = {
+ intl: intlShape,
+ };
+
+ computeTextStyle = (baseStyle, context) => {
+ return concatStyles(baseStyle, context.map((type) => this.props.textStyles[type]));
+ }
+
+ handleAddChannelMember = () => {
+ const {
+ actions,
+ currentUser,
+ post,
+ userIds,
+ usernames,
+ } = this.props;
+
+ const {formatMessage} = this.context.intl;
+
+ if (post && post.channel_id) {
+ userIds.forEach((userId, index) => {
+ actions.addChannelMember(post.channel_id, userId);
+
+ if (post.root_id) {
+ const message = formatMessage(
+ {
+ id: 'api.channel.add_member.added',
+ defaultMessage: '{addedUsername} added to the channel by {username}.',
+ },
+ {
+ username: currentUser.username,
+ addedUsername: usernames[index],
+ }
+ );
+
+ actions.sendAddToChannelEphemeralPost(currentUser, usernames[index], message, post.channel_id, post.root_id);
+ }
+ });
+
+ actions.removePost(post);
+ }
+ }
+
+ generateAtMentions(usernames = []) {
+ if (usernames.length === 1) {
+ return (
+
+ );
+ } else if (usernames.length > 1) {
+ function andSeparator(key) {
+ return (
+
+ );
+ }
+
+ function commaSeparator(key) {
+ return {', '};
+ }
+
+ return (
+
+ {
+ usernames.map((username) => {
+ return (
+
+ );
+ }).reduce((acc, el, idx, arr) => {
+ if (idx === 0) {
+ return [el];
+ } else if (idx === arr.length - 1) {
+ return [...acc, andSeparator(idx), el];
+ }
+
+ return [...acc, commaSeparator(idx), el];
+ }, [])
+ }
+
+ );
+ }
+
+ return '';
+ }
+
+ render() {
+ const {channelType, postId, usernames} = this.props;
+ if (!postId || !channelType) {
+ return null;
+ }
+
+ let linkId;
+ let linkText;
+ if (channelType === General.PRIVATE_CHANNEL) {
+ linkId = 'post_body.check_for_out_of_channel_mentions.link.private';
+ linkText = 'add them to this private channel';
+ } else if (channelType === General.OPEN_CHANNEL) {
+ linkId = 'post_body.check_for_out_of_channel_mentions.link.public';
+ linkText = 'add them to the channel';
+ }
+
+ let messageId;
+ let messageText;
+ if (usernames.length === 1) {
+ messageId = 'post_body.check_for_out_of_channel_mentions.message.one';
+ messageText = 'was mentioned but is not in the channel. Would you like to ';
+ } else if (usernames.length > 1) {
+ messageId = 'post_body.check_for_out_of_channel_mentions.message.multiple';
+ messageText = 'were mentioned but they are not in the channel. Would you like to ';
+ }
+
+ const atMentions = this.generateAtMentions(usernames);
+
+ return (
+
+ {atMentions}
+ {' '}
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/app/components/post_body/index.js b/app/components/post_body/index.js
index 6cdd9fb9a..bcb11f8d7 100644
--- a/app/components/post_body/index.js
+++ b/app/components/post_body/index.js
@@ -5,10 +5,25 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {flagPost, unflagPost} from 'mattermost-redux/actions/posts';
-import {Posts} from 'mattermost-redux/constants';
+import {
+ General,
+ Posts,
+} from 'mattermost-redux/constants';
+import {
+ getCurrentChannel,
+ getMyCurrentChannelMembership,
+} from 'mattermost-redux/selectors/entities/channels';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
-import {isEdited, isPostEphemeral, isSystemMessage} from 'mattermost-redux/utils/post_utils';
+import {getCurrentTeamMembership} from 'mattermost-redux/selectors/entities/teams';
+import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
+
+import {canManageMembers} from 'mattermost-redux/utils/channel_utils';
+import {
+ isEdited,
+ isPostEphemeral,
+ isSystemMessage,
+} from 'mattermost-redux/utils/post_utils';
import PostBody from './post_body';
@@ -26,6 +41,25 @@ function mapStateToProps(state, ownProps) {
isPending = false;
}
+ const channel = getCurrentChannel(state);
+ const user = getCurrentUser(state);
+ const teamMember = getCurrentTeamMembership(state);
+ const channelMember = getMyCurrentChannelMembership(state);
+ const {config, license} = state.entities.general;
+ const isUserCanManageMembers = canManageMembers(channel, user, teamMember, channelMember, config, license);
+ const isEphemeralPost = isPostEphemeral(post);
+
+ let isPostAddChannelMember = false;
+ if (
+ (channel.type === General.PRIVATE_CHANNEL || channel.type === General.OPEN_CHANNEL) &&
+ isUserCanManageMembers &&
+ isEphemeralPost &&
+ post.props &&
+ post.props.add_channel_member
+ ) {
+ isPostAddChannelMember = true;
+ }
+
return {
postProps: post.props || {},
fileIds: post.file_ids,
@@ -34,7 +68,8 @@ function mapStateToProps(state, ownProps) {
hasReactions: post.has_reactions,
isFailed,
isPending,
- isPostEphemeral: isPostEphemeral(post),
+ isPostAddChannelMember,
+ isPostEphemeral: isEphemeralPost,
isSystemMessage: isSystemMessage(post),
message: post.message,
theme: getTheme(state),
diff --git a/app/components/post_body/post_body.js b/app/components/post_body/post_body.js
index 93f3e2e70..f8802cdcf 100644
--- a/app/components/post_body/post_body.js
+++ b/app/components/post_body/post_body.js
@@ -19,6 +19,7 @@ import FileAttachmentList from 'app/components/file_attachment_list';
import FormattedText from 'app/components/formatted_text';
import Markdown from 'app/components/markdown';
import OptionsContext from 'app/components/options_context';
+import PostAddChannelMember from 'app/components/post_add_channel_member';
import PostBodyAdditionalContent from 'app/components/post_body_additional_content';
@@ -44,6 +45,7 @@ export default class PostBody extends PureComponent {
isFailed: PropTypes.bool,
isFlagged: PropTypes.bool,
isPending: PropTypes.bool,
+ isPostAddChannelMember: PropTypes.bool,
isPostEphemeral: PropTypes.bool,
isReplyPost: PropTypes.bool,
isSearchResult: PropTypes.bool,
@@ -344,6 +346,7 @@ export default class PostBody extends PureComponent {
hasBeenEdited,
isFailed,
isPending,
+ isPostAddChannelMember,
isSearchResult,
isSystemMessage,
message,
@@ -351,6 +354,7 @@ export default class PostBody extends PureComponent {
onFailedPostPress,
onPermalinkPress,
onPress,
+ postProps,
renderReplyBar,
theme,
toggleSelected,
@@ -382,6 +386,23 @@ export default class PostBody extends PureComponent {
);
body = ({messageComponent});
+ } else if (isPostAddChannelMember) {
+ messageComponent = (
+
+
+
+
+
+ );
} else if (message.length) {
messageComponent = (
diff --git a/app/selectors/post_list.js b/app/selectors/post_list.js
index 07bb43d40..002118e88 100644
--- a/app/selectors/post_list.js
+++ b/app/selectors/post_list.js
@@ -24,11 +24,12 @@ export function makePreparePostIdsForPostList() {
return createIdsSelector(
(state, props) => getMyPosts(state, props.postIds),
+ (state) => state.entities.posts.selectedPostId,
(state, props) => props.lastViewedAt,
(state, props) => props.indicateNewMessages,
getCurrentUser,
shouldShowJoinLeaveMessages,
- (posts, lastViewedAt, indicateNewMessages, currentUser, showJoinLeave) => {
+ (posts, selectedPostId, lastViewedAt, indicateNewMessages, currentUser, showJoinLeave) => {
if (posts.length === 0 || !currentUser) {
return [];
}
@@ -42,6 +43,10 @@ export function makePreparePostIdsForPostList() {
for (let i = posts.length - 1; i >= 0; i--) {
const post = posts[i];
+ if (post.type === Posts.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL && !selectedPostId) {
+ continue;
+ }
+
if (post.state === Posts.POST_DELETED && post.user_id === currentUser.id) {
continue;
}
diff --git a/test/app/selectors/post_list.test.js b/test/app/selectors/post_list.test.js
index 23ac64094..bff0a71f1 100644
--- a/test/app/selectors/post_list.test.js
+++ b/test/app/selectors/post_list.test.js
@@ -21,7 +21,7 @@ describe('Selectors.PostList', () => {
entities: {
posts: {
posts: {
- 1001: {id: '1001', create_at: 0},
+ 1001: {id: '1001', create_at: 0, type: ''},
1002: {id: '1002', create_at: 1, type: Posts.POST_TYPES.JOIN_CHANNEL},
},
},
@@ -115,9 +115,9 @@ describe('Selectors.PostList', () => {
entities: {
posts: {
posts: {
- 1000: {id: '1000', create_at: 1000},
- 1005: {id: '1005', create_at: 1005},
- 1010: {id: '1010', create_at: 1010},
+ 1000: {id: '1000', create_at: 1000, type: ''},
+ 1005: {id: '1005', create_at: 1005, type: ''},
+ 1010: {id: '1010', create_at: 1010, type: ''},
},
},
preferences: {
@@ -152,11 +152,11 @@ describe('Selectors.PostList', () => {
// Posts 7 hours apart so they should appear on multiple days
const initialPosts = {
- 1001: {id: '1001', create_at: 1 * 60 * 60 * 1000},
- 1002: {id: '1002', create_at: (1 * 60 * 60 * 1000) + 5},
- 1003: {id: '1003', create_at: (1 * 60 * 60 * 1000) + 10},
- 1004: {id: '1004', create_at: 25 * 60 * 60 * 1000},
- 1005: {id: '1005', create_at: (25 * 60 * 60 * 1000) + 5},
+ 1001: {id: '1001', create_at: 1 * 60 * 60 * 1000, type: ''},
+ 1002: {id: '1002', create_at: (1 * 60 * 60 * 1000) + 5, type: ''},
+ 1003: {id: '1003', create_at: (1 * 60 * 60 * 1000) + 10, type: ''},
+ 1004: {id: '1004', create_at: 25 * 60 * 60 * 1000, type: ''},
+ 1005: {id: '1005', create_at: (25 * 60 * 60 * 1000) + 5, type: ''},
1006: {id: '1006', create_at: (25 * 60 * 60 * 1000) + 10, type: Posts.POST_TYPES.JOIN_CHANNEL},
};
let state = {