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 = {