From 55bd6a2f7e091b5ea262275d9781edb050cf4a95 Mon Sep 17 00:00:00 2001 From: Hossein Ahmadian-Yazdi Date: Thu, 8 Aug 2019 16:21:52 -0400 Subject: [PATCH] [MM-13849] Display confirmation box for [at]all and [at]channel (#3028) * [MM-13849] Display confirmation box for @all and @channel * [MM-13849] Fetch Channel stats when user changes channel * [MM-13849] Address PR comments * [MM-13849] Addressed PR comments * [MM-13849] Made getChannelStats into it's own statement --- app/components/post_textbox/index.js | 12 +- .../post_textbox/post_textbox.test.js | 157 ++++++++++++++ .../post_textbox/post_textbox_base.js | 195 +++++++++++++----- app/constants/view.js | 2 + app/screens/channel/channel_base.js | 7 + app/screens/channel/index.js | 2 + assets/base/i18n/en.json | 3 + 7 files changed, 324 insertions(+), 54 deletions(-) diff --git a/app/components/post_textbox/index.js b/app/components/post_textbox/index.js index d651b030e..26e72bab0 100644 --- a/app/components/post_textbox/index.js +++ b/app/components/post_textbox/index.js @@ -7,10 +7,11 @@ import {connect} from 'react-redux'; import {General} from 'mattermost-redux/constants'; import {createPost} from 'mattermost-redux/actions/posts'; import {setStatus} from 'mattermost-redux/actions/users'; -import {getCurrentChannel, isCurrentChannelReadOnly} from 'mattermost-redux/selectors/entities/channels'; +import {getCurrentChannel, isCurrentChannelReadOnly, getCurrentChannelStats} from 'mattermost-redux/selectors/entities/channels'; import {canUploadFilesOnMobile, getConfig} from 'mattermost-redux/selectors/entities/general'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; +import {getChannelTimezones} from 'mattermost-redux/actions/channels'; import {executeCommand} from 'app/actions/views/command'; import {addReactionToLatestPost} from 'app/actions/views/emoji'; @@ -42,8 +43,13 @@ function mapStateToProps(state, ownProps) { const currentUserId = getCurrentUserId(state); const status = getStatusForUserId(state, currentUserId); const userIsOutOfOffice = status === General.OUT_OF_OFFICE; + const enableConfirmNotificationsToChannel = config?.EnableConfirmNotificationsToChannel === 'true'; + const currentChannelStats = getCurrentChannelStats(state); + const currentChannelMembersCount = currentChannelStats?.member_count || 0; // eslint-disable-line camelcase + const isTimezoneEnabled = config?.ExperimentalTimezone === 'true'; return { + currentChannel, channelId: ownProps.channelId || (currentChannel ? currentChannel.id : ''), channelTeamId: currentChannel ? currentChannel.team_id : '', canUploadFiles: canUploadFilesOnMobile(state), @@ -60,6 +66,9 @@ function mapStateToProps(state, ownProps) { theme: getTheme(state), uploadFileRequestStatus: state.requests.files.uploadFiles.status, value: currentDraft.draft, + enableConfirmNotificationsToChannel, + currentChannelMembersCount, + isTimezoneEnabled, }; } @@ -79,6 +88,7 @@ function mapDispatchToProps(dispatch) { handleCommentDraftSelectionChanged, setStatus, selectPenultimateChannel, + getChannelTimezones, }, dispatch), }; } diff --git a/app/components/post_textbox/post_textbox.test.js b/app/components/post_textbox/post_textbox.test.js index 2dcc12e87..44940a24e 100644 --- a/app/components/post_textbox/post_textbox.test.js +++ b/app/components/post_textbox/post_textbox.test.js @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import React from 'react'; +import assert from 'assert'; import {shallowWithIntl} from 'test/intl-test-helper'; import Preferences from 'mattermost-redux/constants/preferences'; @@ -26,6 +27,7 @@ describe('PostTextBox', () => { handleCommentDraftSelectionChanged: jest.fn(), setStatus: jest.fn(), selectPenultimateChannel: jest.fn(), + getChannelTimezones: jest.fn(), }, canUploadFiles: true, channelId: 'channel-id', @@ -84,4 +86,159 @@ describe('PostTextBox', () => { expect(baseProps.actions.handlePostDraftChanged).toHaveBeenCalledWith(baseProps.channelId, value); expect(baseProps.actions.handlePostDraftChanged).toHaveBeenCalledTimes(1); }); + + test('should return correct @all (same for @channel)', () => { + for (const data of [ + { + text: '', + result: false, + }, + { + text: 'all', + result: false, + }, + { + text: '@allison', + result: false, + }, + { + text: '@ALLISON', + result: false, + }, + { + text: '@all123', + result: false, + }, + { + text: '123@all', + result: false, + }, + { + text: 'hey@all', + result: false, + }, + { + text: 'hey@all.com', + result: false, + }, + { + text: '@all', + result: true, + }, + { + text: '@ALL', + result: true, + }, + { + text: '@all hey', + result: true, + }, + { + text: 'hey @all', + result: true, + }, + { + text: 'HEY @ALL', + result: true, + }, + { + text: 'hey @all!', + result: true, + }, + { + text: 'hey @all:+1:', + result: true, + }, + { + text: 'hey @ALL:+1:', + result: true, + }, + { + text: '`@all`', + result: false, + }, + { + text: '@someone `@all`', + result: false, + }, + { + text: '``@all``', + result: false, + }, + { + text: '```@all```', + result: false, + }, + { + text: '```\n@all\n```', + result: false, + }, + { + text: '```````\n@all\n```````', + result: false, + }, + { + text: '```code\n@all\n```', + result: false, + }, + { + text: '~~~@all~~~', + result: true, + }, + { + text: '~~~\n@all\n~~~', + result: false, + }, + { + text: ' /not_cmd @all', + result: true, + }, + { + text: '@channel', + result: true, + }, + { + text: '@channel.', + result: true, + }, + { + text: '@channel/test', + result: true, + }, + { + text: 'test/@channel', + result: true, + }, + { + text: '@all/@channel', + result: true, + }, + { + text: '@cha*nnel*', + result: false, + }, + { + text: '@cha**nnel**', + result: false, + }, + { + text: '*@cha*nnel', + result: false, + }, + { + text: '[@chan](https://google.com)nel', + result: false, + }, + { + text: '@cha![](https://myimage)nnel', + result: false, + }, + ]) { + const wrapper = shallowWithIntl( + + ); + const containsAtChannel = wrapper.instance().textContainsAtAllAtChannel(data.text); + assert.equal(containsAtChannel, data.result, data.text); + } + }); }); diff --git a/app/components/post_textbox/post_textbox_base.js b/app/components/post_textbox/post_textbox_base.js index 0fa6403b8..249129f45 100644 --- a/app/components/post_textbox/post_textbox_base.js +++ b/app/components/post_textbox/post_textbox_base.js @@ -28,6 +28,7 @@ import FormattedText from 'app/components/formatted_text'; import SendButton from 'app/components/send_button'; import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_CONTENT_HEIGHT, MAX_FILE_COUNT} from 'app/constants/post_textbox'; +import {NOTIFY_ALL_MEMBERS} from 'app/constants/view'; import {t} from 'app/utils/i18n'; import {confirmOutOfOfficeDisabled} from 'app/utils/status'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; @@ -50,6 +51,7 @@ export default class PostTextBoxBase extends PureComponent { handleCommentDraftSelectionChanged: PropTypes.func.isRequired, setStatus: PropTypes.func.isRequired, selectPenultimateChannel: PropTypes.func.isRequired, + getChannelTimezones: PropTypes.func.isRequired, }).isRequired, canUploadFiles: PropTypes.bool.isRequired, channelId: PropTypes.string.isRequired, @@ -71,6 +73,10 @@ export default class PostTextBoxBase extends PureComponent { onCloseChannel: PropTypes.func, cursorPositionEvent: PropTypes.string, valueEvent: PropTypes.string, + currentChannelMembersCount: PropTypes.number, + enableConfirmNotificationsToChannel: PropTypes.bool, + isTimezoneEnabled: PropTypes.bool, + currentChannel: PropTypes.object, }; static defaultProps = { @@ -93,10 +99,11 @@ export default class PostTextBoxBase extends PureComponent { keyboardType: 'default', top: 0, value: props.value, + channelTimezoneCount: 0, }; } - componentDidMount() { + componentDidMount(prevProps) { const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT; EventEmitter.on(event, this.handleInsertTextToDraft); @@ -106,6 +113,10 @@ export default class PostTextBoxBase extends PureComponent { Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard); BackHandler.addEventListener('hardwareBackPress', this.handleAndroidBack); } + + if (this.props.isTimezoneEnabled !== prevProps?.isTimezoneEnabled || prevProps?.channelId !== this.props.channelId) { + this.numberOfTimezones().then((channelTimezoneCount) => this.setState({channelTimezoneCount})); + } } componentWillReceiveProps(nextProps) { @@ -132,6 +143,11 @@ export default class PostTextBoxBase extends PureComponent { } }; + numberOfTimezones = async () => { + const {data} = await this.props.actions.getChannelTimezones(this.props.channelId); + return data?.length || 0; + } + canSend = () => { const {files, maxMessageLength, uploadFileRequestStatus} = this.props; const {value} = this.state; @@ -389,10 +405,9 @@ export default class PostTextBoxBase extends PureComponent { } sendMessage = () => { - const {actions, currentUserId, channelId, files, rootId} = this.props; + const {files} = this.props; const {intl} = this.context; const {value} = this.state; - if (files.length === 0 && !value) { Alert.alert( intl.formatMessage({ @@ -408,63 +423,137 @@ export default class PostTextBoxBase extends PureComponent { }], ); } else { + const currentMembersCount = this.props.currentChannelMembersCount; + const notificationsToChannel = this.props.enableConfirmNotificationsToChannel; + const toAllorChannel = this.textContainsAtAllAtChannel(value); + if (value.indexOf('/') === 0) { this.sendCommand(value); + } else if (notificationsToChannel && currentMembersCount > NOTIFY_ALL_MEMBERS && toAllorChannel) { + this.showSendToAllOrChannelAlert(currentMembersCount); } else { - const postFiles = files.filter((f) => !f.failed); - const post = { - user_id: currentUserId, - channel_id: channelId, - root_id: rootId, - parent_id: rootId, - message: value, - }; - - actions.createPost(post, postFiles); - - if (postFiles.length) { - actions.handleClearFiles(channelId, rootId); - } + this.doSubmitMessage(); } - - if (Platform.OS === 'ios') { - // On iOS, if the PostTextbox height increases from its - // initial height (due to a multiline post or a post whose - // message wraps, for example), then when the text is cleared - // the PostTextbox height decrease will be animated. This - // animation in conjunction with the PostList animation as it - // receives the newly created post is causing issues in the iOS - // PostList component as it fails to properly react to its content - // size changes. While a proper fix is determined for the PostList - // component, a small delay in triggering the height decrease - // animation gives the PostList enough time to first handle content - // size changes from the new post. - setTimeout(() => { - this.handleTextChange(''); - }, 250); - } else { - this.handleTextChange(''); - } - - this.changeDraft(''); - - let callback; - if (Platform.OS === 'android') { - // Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages - // are typed successively without blurring the input - const nextState = { - keyboardType: 'email-address', - }; - - callback = () => this.setState({keyboardType: 'default'}); - - this.setState(nextState, callback); - } - - EventEmitter.emit('scroll-to-bottom'); } }; + textContainsAtAllAtChannel = (text) => { + const textWithoutCode = text.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)| *(`{3,}|~{3,})[ .]*(\S+)? *\n([\s\S]*?\s*)\3 *(?:\n+|$)/g, ''); + return (/\B@(all|channel)\b/i).test(textWithoutCode); + } + + showSendToAllOrChannelAlert = (currentMembersCount) => { + const {intl} = this.context; + const {channelTimezoneCount} = this.state; + const {isTimezoneEnabled} = this.props; + + let notifyAllMessage = ''; + if (isTimezoneEnabled && channelTimezoneCount) { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.entire_channel.message.with_timezones', + defaultMessage: 'By using @all or @channel you are about to send notifications to {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', + }, + { + totalMembers: currentMembersCount - 1, + timezones: channelTimezoneCount, + } + ) + ); + } else { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.entire_channel.message', + defaultMessage: 'By using @all or @channel you are about to send notifications to {totalMembers} people. Are you sure you want to do this?', + }, + { + totalMembers: currentMembersCount - 1, + } + ) + ); + } + + Alert.alert( + intl.formatMessage({ + id: 'mobile.post_textbox.entire_channel.title', + defaultMessage: 'Confirm sending notifications to entire channel', + }), + notifyAllMessage, + [ + { + text: intl.formatMessage({ + id: 'mobile.post_textbox.entire_channel.cancel', + defaultMessage: 'Cancel', + }), + }, + { + text: intl.formatMessage({ + id: 'mobile.post_textbox.entire_channel.confirm', + defaultMessage: 'Confirm', + }), + onPress: () => this.doSubmitMessage(), + }, + ], + ); + } + + doSubmitMessage = () => { + const {actions, currentUserId, channelId, files, rootId} = this.props; + const {value} = this.state; + const postFiles = files.filter((f) => !f.failed); + const post = { + user_id: currentUserId, + channel_id: channelId, + root_id: rootId, + parent_id: rootId, + message: value, + }; + + actions.createPost(post, postFiles); + + if (postFiles.length) { + actions.handleClearFiles(channelId, rootId); + } + + if (Platform.OS === 'ios') { + // On iOS, if the PostTextbox height increases from its + // initial height (due to a multiline post or a post whose + // message wraps, for example), then when the text is cleared + // the PostTextbox height decrease will be animated. This + // animation in conjunction with the PostList animation as it + // receives the newly created post is causing issues in the iOS + // PostList component as it fails to properly react to its content + // size changes. While a proper fix is determined for the PostList + // component, a small delay in triggering the height decrease + // animation gives the PostList enough time to first handle content + // size changes from the new post. + setTimeout(() => { + this.handleTextChange(''); + }, 250); + } else { + this.handleTextChange(''); + } + + this.changeDraft(''); + + let callback; + if (Platform.OS === 'android') { + // Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages + // are typed successively without blurring the input + const nextState = { + keyboardType: 'email-address', + }; + + callback = () => this.setState({keyboardType: 'default'}); + + this.setState(nextState, callback); + } + + EventEmitter.emit('scroll-to-bottom'); + } + getStatusFromSlashCommand = (message) => { const tokens = message.split(' '); diff --git a/app/constants/view.js b/app/constants/view.js index 7881ef1a6..0f50aa7f9 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -27,6 +27,8 @@ export const NotificationLevels = { NONE: 'none', }; +export const NOTIFY_ALL_MEMBERS = 5; + const ViewTypes = keyMirror({ DATA_CLEANUP: null, SERVER_URL_CHANGED: null, diff --git a/app/screens/channel/channel_base.js b/app/screens/channel/channel_base.js index 52b9cc7a1..a4be07f4c 100644 --- a/app/screens/channel/channel_base.js +++ b/app/screens/channel/channel_base.js @@ -41,6 +41,7 @@ export default class ChannelBase extends PureComponent { peek: PropTypes.func.isRequired, goToScreen: PropTypes.func.isRequired, showModalOverCurrentContext: PropTypes.func.isRequired, + getChannelStats: PropTypes.func.isRequired, }).isRequired, componentId: PropTypes.string.isRequired, currentChannelId: PropTypes.string, @@ -106,6 +107,8 @@ export default class ChannelBase extends PureComponent { if (!this.props.skipMetrics) { telemetry.end(['start:channel_screen']); } + + this.props.actions.getChannelStats(this.props.currentChannelId); } componentWillReceiveProps(nextProps) { @@ -126,6 +129,10 @@ export default class ChannelBase extends PureComponent { PushNotifications.clearChannelNotifications(nextProps.currentChannelId); } + if (nextProps.currentChannelId !== this.props.currentChannelId) { + this.props.actions.getChannelStats(nextProps.currentChannelId); + } + if (LocalConfig.EnableMobileClientUpgrade && !ClientUpgradeListener) { ClientUpgradeListener = require('app/components/client_upgrade_listener').default; } diff --git a/app/screens/channel/index.js b/app/screens/channel/index.js index e26fc7e13..cbfd5bc02 100644 --- a/app/screens/channel/index.js +++ b/app/screens/channel/index.js @@ -10,6 +10,7 @@ import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels' import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {shouldShowTermsOfService} from 'mattermost-redux/selectors/entities/users'; +import {getChannelStats} from 'mattermost-redux/actions/channels'; import { loadChannelsIfNecessary, @@ -40,6 +41,7 @@ function mapStateToProps(state) { function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ + getChannelStats, connection, loadChannelsIfNecessary, loadProfilesAndTeamMembersForDMSidebar, diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index fbb3099f8..f568af1fa 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -355,6 +355,9 @@ "mobile.post_textbox.empty.message": "You are trying to send an empty message.\nPlease make sure you have a message or at least one attached file.", "mobile.post_textbox.empty.ok": "OK", "mobile.post_textbox.empty.title": "Empty Message", + "mobile.post_textbox.entire_channel.cancel": "Cancel", + "mobile.post_textbox.entire_channel.confirm": "Confirm", + "mobile.post_textbox.entire_channel.title": "Confirm sending notifications to entire channel", "mobile.post_textbox.uploadFailedDesc": "Some attachments failed to upload to the server. Are you sure you want to post the message?", "mobile.post_textbox.uploadFailedTitle": "Attachment failure", "mobile.post.cancel": "Cancel",