[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:
parent
fddb0cf2fb
commit
55bd6a2f7e
7 changed files with 324 additions and 54 deletions
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '@channel',
|
||||
result: false,
|
||||
},
|
||||
]) {
|
||||
const wrapper = shallowWithIntl(
|
||||
<PostTextbox {...baseProps}/>
|
||||
);
|
||||
const containsAtChannel = wrapper.instance().textContainsAtAllAtChannel(data.text);
|
||||
assert.equal(containsAtChannel, data.result, data.text);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(' ');
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue