[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
This commit is contained in:
Hossein Ahmadian-Yazdi 2019-08-08 16:21:52 -04:00 committed by Elias Nahum
parent fddb0cf2fb
commit 55bd6a2f7e
7 changed files with 324 additions and 54 deletions

View file

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

View file

@ -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(
<PostTextbox {...baseProps}/>
);
const containsAtChannel = wrapper.instance().textContainsAtAllAtChannel(data.text);
assert.equal(containsAtChannel, data.result, data.text);
}
});
});

View file

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

View file

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

View file

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

View file

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

View file

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