From fc815adaebd4efa6e5e83536e9ebcff08ccd0324 Mon Sep 17 00:00:00 2001 From: Hossein Ahmadian-Yazdi Date: Fri, 17 Jul 2020 12:30:43 -0400 Subject: [PATCH] [MM 23785] Show confirmation dialogue when mention groups 2 (#4548) (#4583) * Show Confirmation Dialogue WIP First Commit Show Confirmation Dialogue WIP Second Commit refactoring according to comments refactor code according to comments Fix linting problems add i18n strings Update regex pattern add test and make fixes fix message not submitting Fix linting fix index.js fix conflicts address PR comments address PR comments single dispatch Address PR comments add test * Show Confirmation Dialogue WIP First Commit Show Confirmation Dialogue WIP Second Commit refactoring according to comments refactor code according to comments Fix linting problems add i18n strings Update regex pattern add test and make fixes fix message not submitting Fix linting fix index.js fix conflicts address PR comments address PR comments single dispatch Address PR comments add test * make some changes * fix test failures * Address PR comments * Update app/mm-redux/types/channels.ts Co-authored-by: Elias Nahum * Update app/mm-redux/selectors/entities/channels.ts Co-authored-by: Elias Nahum * Update app/mm-redux/selectors/entities/channels.test.js Co-authored-by: Elias Nahum * Update app/constants/autocomplete.js Co-authored-by: Elias Nahum * Address PR comments * make group mention mapping its own function * Address PR comments * Update app/components/post_draft/post_draft.js Co-authored-by: Elias Nahum * Merge branch 'master' of https://github.com/mattermost/mattermost-mobile into MM-23785-ShowConfirmationDialogue-2 * Merge branch 'master' into MM-23785-ShowConfirmationDialogue-2 * Address MM-26987 * Retrieve group information on mount RN: Group Mention confirmation prompt not shown on default channel load * Update Regex to fix MM-26976 Co-authored-by: Elias Nahum Co-authored-by: Elias Nahum --- app/components/post_draft/index.js | 42 ++- app/components/post_draft/index.test.js | 6 + app/components/post_draft/post_draft.js | 168 ++++++++- app/components/post_draft/post_draft.test.js | 349 ++++++++++++++++++ app/constants/autocomplete.js | 6 +- app/mm-redux/action_types/channels.ts | 2 + app/mm-redux/actions/channels.test.js | 30 ++ app/mm-redux/actions/channels.ts | 20 + app/mm-redux/client/client4.ts | 7 + .../reducers/entities/channels.test.js | 131 +++++++ app/mm-redux/reducers/entities/channels.ts | 32 +- .../selectors/entities/channels.test.js | 29 ++ app/mm-redux/selectors/entities/channels.ts | 6 +- app/mm-redux/selectors/entities/groups.ts | 7 + app/mm-redux/types/channels.ts | 9 + assets/base/i18n/en.json | 5 + 16 files changed, 828 insertions(+), 21 deletions(-) create mode 100644 app/components/post_draft/post_draft.test.js diff --git a/app/components/post_draft/index.js b/app/components/post_draft/index.js index 2be1f693f..f52f3ce09 100644 --- a/app/components/post_draft/index.js +++ b/app/components/post_draft/index.js @@ -7,12 +7,13 @@ import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {General, Permissions} from '@mm-redux/constants'; import {createPost} from '@mm-redux/actions/posts'; import {setStatus} from '@mm-redux/actions/users'; -import {getCurrentChannel, isCurrentChannelReadOnly, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels'; +import {getCurrentChannel, isCurrentChannelReadOnly, getCurrentChannelStats, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from '@mm-redux/selectors/entities/channels'; import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles'; -import {getConfig} from '@mm-redux/selectors/entities/general'; +import {getConfig, getLicense} from '@mm-redux/selectors/entities/general'; import {getTheme} from '@mm-redux/selectors/entities/preferences'; import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities/users'; -import {getChannelTimezones} from '@mm-redux/actions/channels'; +import {getChannelTimezones, getChannelMemberCountsByGroup} from '@mm-redux/actions/channels'; +import {getAssociatedGroupsForReferenceMap} from '@mm-redux/selectors/entities/groups'; import {executeCommand} from '@actions/views/command'; import {addReactionToLatestPost} from '@actions/views/emoji'; @@ -36,8 +37,16 @@ export function mapStateToProps(state, ownProps) { const currentChannelStats = getCurrentChannelStats(state); const membersCount = currentChannelStats?.member_count || 0; // eslint-disable-line camelcase const isTimezoneEnabled = config?.ExperimentalTimezone === 'true'; - + const channelId = ownProps.channelId || (currentChannel ? currentChannel.id : ''); + const channelTeamId = currentChannel ? currentChannel.team_id : ''; + const license = getLicense(state); + let canPost = true; + let useChannelMentions = true; let deactivatedChannel = false; + let useGroupMentions = false; + const channelMemberCountsByGroup = selectChannelMemberCountsByGroup(state, channelId); + let groupsWithAllowReference = new Map(); + if (currentChannel && currentChannel.type === General.DM_CHANNEL) { const teammate = getChannelMembersForDm(state, currentChannel); if (teammate.length && teammate[0].delete_at) { @@ -45,8 +54,6 @@ export function mapStateToProps(state, ownProps) { } } - let canPost = true; - let useChannelMentions = true; if (currentChannel && isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) { canPost = haveIChannelPermission( state, @@ -68,7 +75,20 @@ export function mapStateToProps(state, ownProps) { ); } - const channelId = ownProps.channelId || (currentChannel ? currentChannel.id : ''); + if (isMinimumServerVersion(state.entities.general.serverVersion, 5, 24) && license && license.IsLicensed === 'true') { + useGroupMentions = haveIChannelPermission( + state, + { + channel: currentChannel.id, + team: currentChannel.team_id, + permission: Permissions.USE_GROUP_MENTIONS, + }, + ); + + if (useGroupMentions) { + groupsWithAllowReference = getAssociatedGroupsForReferenceMap(state, channelTeamId, channelId); + } + } let channelIsReadOnly = false; if (currentUserId && channelId) { @@ -77,8 +97,10 @@ export function mapStateToProps(state, ownProps) { return { canPost, - channelDisplayName: state.views.channel.displayName || (currentChannel ? currentChannel.display_name : ''), + currentChannel, channelId, + channelTeamId, + channelDisplayName: state.views.channel.displayName || (currentChannel ? currentChannel.display_name : ''), channelIsArchived: ownProps.channelIsArchived || (currentChannel ? currentChannel.delete_at !== 0 : false), channelIsReadOnly, currentUserId, @@ -94,6 +116,9 @@ export function mapStateToProps(state, ownProps) { useChannelMentions, userIsOutOfOffice, value: currentDraft.draft, + groupsWithAllowReference, + useGroupMentions, + channelMemberCountsByGroup, }; } @@ -106,6 +131,7 @@ const mapDispatchToProps = { handleClearFailedFiles, initUploadFiles, setStatus, + getChannelMemberCountsByGroup, }; export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostDraft); diff --git a/app/components/post_draft/index.test.js b/app/components/post_draft/index.test.js index 8e9ca2980..2c9a7e881 100644 --- a/app/components/post_draft/index.test.js +++ b/app/components/post_draft/index.test.js @@ -37,14 +37,20 @@ describe('mapStateToProps', () => { serverVersion: '', }, users: { + profiles: {}, currentUserId: '', }, channels: { currentChannelId: '', + channelMemberCountsByGroup: {}, + channels: {}, }, preferences: { myPreferences: {}, }, + teams: { + teams: {}, + }, }, views: { channel: { diff --git a/app/components/post_draft/post_draft.js b/app/components/post_draft/post_draft.js index b00e7791e..0798ab8ff 100644 --- a/app/components/post_draft/post_draft.js +++ b/app/components/post_draft/post_draft.js @@ -11,6 +11,7 @@ import Autocomplete from '@components/autocomplete'; import {paddingHorizontal as padding} from '@components/safe_area_view/iphone_x_spacing'; import {CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, CHANNEL_POST_TEXTBOX_VALUE_CHANGE, IS_REACTION_REGEX, MAX_FILE_COUNT} from '@constants/post_draft'; import {NOTIFY_ALL_MEMBERS} from '@constants/view'; +import {AT_MENTION_REGEX_GLOBAL, CODE_REGEX} from 'app/constants/autocomplete'; import {General} from '@mm-redux/constants'; import EventEmitter from '@mm-redux/utils/event_emitter'; import {getFormattedFileSize} from '@mm-redux/utils/file_utils'; @@ -31,6 +32,7 @@ export default class PostDraft extends PureComponent { static propTypes = { registerTypingAnimation: PropTypes.func.isRequired, addReactionToLatestPost: PropTypes.func.isRequired, + getChannelMemberCountsByGroup: PropTypes.func.isRequired, canPost: PropTypes.bool.isRequired, channelDisplayName: PropTypes.string, channelId: PropTypes.string.isRequired, @@ -60,6 +62,9 @@ export default class PostDraft extends PureComponent { userIsOutOfOffice: PropTypes.bool.isRequired, value: PropTypes.string.isRequired, valueEvent: PropTypes.string, + useGroupMentions: PropTypes.bool.isRequired, + channelMemberCountsByGroup: PropTypes.object, + groupsWithAllowReference: PropTypes.object, }; static defaultProps = { @@ -89,24 +94,33 @@ export default class PostDraft extends PureComponent { }; } - componentDidMount(prevProps) { - if (this.props.isTimezoneEnabled !== prevProps?.isTimezoneEnabled || prevProps?.channelId !== this.props.channelId) { - this.numberOfTimezones().then((channelTimezoneCount) => this.setState({channelTimezoneCount})); + componentDidMount() { + const {getChannelMemberCountsByGroup, channelId, isTimezoneEnabled, useGroupMentions} = this.props; + if (useGroupMentions) { + getChannelMemberCountsByGroup(channelId, isTimezoneEnabled); } } componentDidUpdate(prevProps) { - if (this.input.current) { - const {channelId, rootId, value} = this.props; - const diffChannel = channelId !== prevProps.channelId; - const diffThread = rootId !== prevProps.rootId; + const {channelId, rootId, value, useGroupMentions, getChannelMemberCountsByGroup, isTimezoneEnabled} = this.props; + const diffChannel = channelId !== prevProps?.channelId; + const diffTimezoneEnabled = isTimezoneEnabled !== prevProps?.isTimezoneEnabled; + if (this.input.current) { + const diffThread = rootId !== prevProps.rootId; if (diffChannel || diffThread) { const trimmed = value.trim(); this.input.current.setValue(trimmed); this.updateInitialValue(trimmed); } } + + if (diffTimezoneEnabled || diffChannel) { + this.numberOfTimezones().then((channelTimezoneCount) => this.setState({channelTimezoneCount})); + if (useGroupMentions) { + getChannelMemberCountsByGroup(channelId, isTimezoneEnabled); + } + } } blurTextBox = () => { @@ -132,6 +146,98 @@ export default class PostDraft extends PureComponent { return messageLength > 0; }; + showSendToGroupsAlert = (groupMentions, memberNotifyCount, channelTimezoneCount, msg) => { + const {intl} = this.context; + + let notifyAllMessage = ''; + if (groupMentions.length === 1) { + if (channelTimezoneCount > 0) { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.one_group.message.with_timezones', + defaultMessage: 'By using {mention} 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?', + }, + { + mention: groupMentions[0], + totalMembers: memberNotifyCount, + timezones: channelTimezoneCount, + }, + ) + ); + } else { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.one_group.message.without_timezones', + defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people. Are you sure you want to do this?', + }, + { + mention: groupMentions[0], + totalMembers: memberNotifyCount, + }, + ) + ); + } + } else if (channelTimezoneCount > 0) { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.multi_group.message.with_timezones', + defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', + }, + { + mentions: groupMentions.slice(0, -1).join(', '), + finalMention: groupMentions[groupMentions.length - 1], + totalMembers: memberNotifyCount, + timezones: channelTimezoneCount, + }, + ) + ); + } else { + notifyAllMessage = ( + intl.formatMessage( + { + id: 'mobile.post_textbox.multi_group.message.without_timezones', + defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people. Are you sure you want to do this?', + }, + { + mentions: groupMentions.slice(0, -1).join(', '), + finalMention: groupMentions[groupMentions.length - 1], + totalMembers: memberNotifyCount, + }, + ) + ); + } + + Alert.alert( + intl.formatMessage({ + id: 'mobile.post_textbox.groups.title', + defaultMessage: 'Confirm sending notifications to groups', + }), + notifyAllMessage, + [ + { + text: intl.formatMessage({ + id: 'mobile.post_textbox.entire_channel.cancel', + defaultMessage: 'Cancel', + }), + onPress: () => { + this.input.current.setValue(msg); + this.setState({sendingMessage: false}); + }, + }, + { + text: intl.formatMessage({ + id: 'mobile.post_textbox.entire_channel.confirm', + defaultMessage: 'Confirm', + }), + onPress: () => this.doSubmitMessage(), + }, + ], + ); + }; + doSubmitMessage = () => { const {createPost, currentUserId, channelId, files, handleClearFiles, rootId} = this.props; const value = this.input.current.getValue(); @@ -362,18 +468,44 @@ export default class PostDraft extends PureComponent { this.input.current.changeDraft(''); }; + mapGroupMentions = (groupMentions) => { + const {channelMemberCountsByGroup} = this.props; + let memberNotifyCount = 0; + let channelTimezoneCount = 0; + const groupMentionsSet = new Set(); + groupMentions. + forEach((group) => { + const mappedValue = channelMemberCountsByGroup[group.id]; + if (mappedValue?.channel_member_count > NOTIFY_ALL_MEMBERS && mappedValue?.channel_member_count > memberNotifyCount) { + memberNotifyCount = mappedValue.channel_member_count; + channelTimezoneCount = mappedValue.channel_member_timezones_count; + } + groupMentionsSet.add(`@${group.name}`); + }); + return {groupMentionsSet, memberNotifyCount, channelTimezoneCount}; + } + sendMessage = () => { const value = this.input.current.getValue(); if (value) { - const {enableConfirmNotificationsToChannel, membersCount, useChannelMentions} = this.props; + const {enableConfirmNotificationsToChannel, membersCount, useGroupMentions, useChannelMentions} = this.props; const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions; + const notificationsToGroups = enableConfirmNotificationsToChannel && useGroupMentions; const toAllOrChannel = this.textContainsAtAllAtChannel(value); + const groupMentions = (!toAllOrChannel && notificationsToGroups) ? this.groupsMentionedInText(value) : []; if (value.indexOf('/') === 0) { this.sendCommand(value); } else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) { this.showSendToAllOrChannelAlert(membersCount, value); + } else if (groupMentions.length > 0) { + const {groupMentionsSet, memberNotifyCount, channelTimezoneCount} = this.mapGroupMentions(groupMentions); + if (memberNotifyCount > 0) { + this.showSendToGroupsAlert(Array.from(groupMentionsSet), memberNotifyCount, channelTimezoneCount, value); + } else { + this.doSubmitMessage(); + } } else { this.doSubmitMessage(); } @@ -474,10 +606,26 @@ export default class PostDraft extends PureComponent { }; 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); + const textWithoutCode = text.replace(CODE_REGEX, ''); + return (/(?:\B|\b_+)@(channel|all)(?!(\.|-|_)*[^\W_])/i).test(textWithoutCode); }; + groupsMentionedInText = (text) => { + const {groupsWithAllowReference} = this.props; + const groups = []; + if (groupsWithAllowReference.size > 0) { + const textWithoutCode = text.replace(CODE_REGEX, ''); + const mentions = textWithoutCode.match(AT_MENTION_REGEX_GLOBAL) || []; + mentions.forEach((mention) => { + const group = groupsWithAllowReference.get(mention); + if (group) { + groups.push(group); + } + }); + } + return groups; + } + updateInitialValue = (value) => { this.setState({value}); } diff --git a/app/components/post_draft/post_draft.test.js b/app/components/post_draft/post_draft.test.js new file mode 100644 index 000000000..f868a2920 --- /dev/null +++ b/app/components/post_draft/post_draft.test.js @@ -0,0 +1,349 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Alert} from 'react-native'; +import assert from 'assert'; +import {shallowWithIntl} from 'test/intl-test-helper'; + +import Preferences from '@mm-redux/constants/preferences'; +import PostDraft from './post_draft'; + +jest.mock('react-native-image-picker', () => ({ + launchCamera: jest.fn(), +})); + +describe('PostDraft', () => { + const baseProps = { + addReactionToLatestPost: jest.fn(), + createPost: jest.fn(), + executeCommand: jest.fn(), + handleCommentDraftChanged: jest.fn(), + handlePostDraftChanged: jest.fn(), + handleClearFiles: jest.fn(), + handleClearFailedFiles: jest.fn(), + handleRemoveLastFile: jest.fn(), + initUploadFiles: jest.fn(), + userTyping: jest.fn(), + handleCommentDraftSelectionChanged: jest.fn(), + setStatus: jest.fn(), + selectPenultimateChannel: jest.fn(), + getChannelTimezones: jest.fn(), + getChannelMemberCountsByGroup: jest.fn(), + canUploadFiles: true, + channelId: 'channel-id', + channelDisplayName: 'Test Channel', + channelTeamId: 'channel-team-id', + channelIsReadOnly: false, + currentUserId: 'current-user-id', + deactivatedChannel: false, + files: [], + maxFileSize: 1024, + maxMessageLength: 4000, + rootId: '', + theme: Preferences.THEMES.default, + uploadFileRequestStatus: 'NOT_STARTED', + value: '', + userIsOutOfOffice: false, + channelIsArchived: false, + onCloseChannel: jest.fn(), + cursorPositionEvent: '', + valueEvent: '', + isLandscape: false, + screenId: 'NavigationScreen1', + canPost: true, + currentChannelMembersCount: 50, + enableConfirmNotificationsToChannel: true, + useChannelMentions: true, + useGroupMentions: true, + groupsWithAllowReference: new Map([ + ['@developers', { + id: 'developers', + name: 'developers', + }], + ['@qa', { + id: 'qa', + name: 'qa', + }], + ]), + channelMemberCountsByGroup: { + developers: { + channel_member_count: 10, + channel_member_timezones_count: 0, + }, + qa: { + channel_member_count: 3, + channel_member_timezones_count: 0, + }, + }, + membersCount: 10, + }; + const ref = React.createRef(); + + test('should send an alert when sending a message with a channel mention', () => { + const wrapper = shallowWithIntl( + , + ); + const message = '@all'; + const instance = wrapper.instance(); + expect(instance.input).toEqual({current: null}); + instance.input = { + current: { + getValue: () => message, + setValue: jest.fn(), + changeDraft: jest.fn(), + }, + }; + + instance.sendMessage(); + expect(Alert.alert).toBeCalled(); + expect(Alert.alert).toHaveBeenCalledWith('Confirm sending notifications to entire channel', expect.anything(), expect.anything()); + }); + + test('should send an alert when sending a message with a group mention with group with count more than NOTIFY_ALL', () => { + const wrapper = shallowWithIntl( + , + ); + const message = '@developers'; + const instance = wrapper.instance(); + expect(instance.input).toEqual({current: null}); + instance.input = { + current: { + getValue: () => message, + setValue: jest.fn(), + changeDraft: jest.fn(), + }, + }; + instance.sendMessage(); + expect(Alert.alert).toBeCalled(); + }); + + test('should not send an alert when sending a message with a group mention with group with count less than NOTIFY_ALL', () => { + const wrapper = shallowWithIntl( + , + ); + const message = '@qa'; + const instance = wrapper.instance(); + expect(instance.input).toEqual({current: null}); + instance.input = { + current: { + getValue: () => message, + setValue: jest.fn(), + changeDraft: jest.fn(), + }, + }; + + instance.sendMessage(); + expect(Alert.alert).not.toBeCalled(); + }); + + test('should not send an alert when sending a message with a channel mention when the user does not have channel mentions permission', () => { + const wrapper = shallowWithIntl( + , + ); + const message = '@all'; + const instance = wrapper.instance(); + expect(instance.input).toEqual({current: null}); + instance.input = { + current: { + getValue: () => message, + setValue: jest.fn(), + changeDraft: jest.fn(), + }, + }; + + instance.sendMessage(); + expect(Alert.alert).not.toHaveBeenCalled(); + }); + + test('should not send an alert when sending a message with a channel mention when the user does not have group mentions permission', () => { + const wrapper = shallowWithIntl( + , + ); + const message = '@developer'; + const instance = wrapper.instance(); + expect(instance.input).toEqual({current: null}); + instance.input = { + current: { + getValue: () => message, + setValue: jest.fn(), + changeDraft: jest.fn(), + }, + }; + + instance.sendMessage(); + expect(Alert.alert).not.toHaveBeenCalled(); + }); + + 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/constants/autocomplete.js b/app/constants/autocomplete.js index 4f9c13f0c..57d0a3fd7 100644 --- a/app/constants/autocomplete.js +++ b/app/constants/autocomplete.js @@ -3,6 +3,8 @@ export const AT_MENTION_REGEX = /\B(@([^@\r\n\s]*))$/i; +export const AT_MENTION_REGEX_GLOBAL = /\B(@([^@\r\n\s]*))/gi; + export const AT_MENTION_SEARCH_REGEX = /\bfrom:\s*(\S*)$/i; export const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i; @@ -11,4 +13,6 @@ export const CHANNEL_MENTION_SEARCH_REGEX = /\b(?:in|channel):\s*(\S*)$/i; export const DATE_MENTION_SEARCH_REGEX = /\b(?:on|before|after):\s*(\S*)$/i; -export const ALL_SEARCH_FLAGS_REGEX = /\b\w+:/g; \ No newline at end of file +export const ALL_SEARCH_FLAGS_REGEX = /\b\w+:/g; + +export const CODE_REGEX = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)| *(`{3,}|~{3,})[ .]*(\S+)? *\n([\s\S]*?\s*)\3 *(?:\n+|$)/g; diff --git a/app/mm-redux/action_types/channels.ts b/app/mm-redux/action_types/channels.ts index 0778fdb4a..3925d2007 100644 --- a/app/mm-redux/action_types/channels.ts +++ b/app/mm-redux/action_types/channels.ts @@ -78,6 +78,8 @@ export default keyMirror({ RECEIVED_CHANNEL_MODERATIONS: null, + RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP: null, + RECEIVED_TOTAL_CHANNEL_COUNT: null, POST_UNREAD_SUCCESS: null, diff --git a/app/mm-redux/actions/channels.test.js b/app/mm-redux/actions/channels.test.js index a7e0f9675..23f5283f9 100644 --- a/app/mm-redux/actions/channels.test.js +++ b/app/mm-redux/actions/channels.test.js @@ -2320,4 +2320,34 @@ describe('Actions.Channels', () => { assert.equal(moderations[0].roles.members, true); assert.equal(moderations[0].roles.guests, false); }); + + it('getChannelMemberCountsByGroup', async () => { + const channelID = 'cid10000000000000000000000'; + + nock(Client4.getBaseRoute()).get( + `/channels/${channelID}/member_counts_by_group?include_timezones=true`). + reply(200, [ + { + group_id: 'group-1', + channel_member_count: 1, + channel_member_timezones_count: 1, + }, + { + group_id: 'group-2', + channel_member_count: 999, + channel_member_timezones_count: 131, + }, + ]); + + await store.dispatch(Actions.getChannelMemberCountsByGroup(channelID, true)); + + const channelMemberCounts = store.getState().entities.channels.channelMemberCountsByGroup[channelID]; + assert.equal(channelMemberCounts['group-1'].group_id, 'group-1'); + assert.equal(channelMemberCounts['group-1'].channel_member_count, 1); + assert.equal(channelMemberCounts['group-1'].channel_member_timezones_count, 1); + + assert.equal(channelMemberCounts['group-2'].group_id, 'group-2'); + assert.equal(channelMemberCounts['group-2'].channel_member_count, 999); + assert.equal(channelMemberCounts['group-2'].channel_member_timezones_count, 131); + }); }); diff --git a/app/mm-redux/actions/channels.ts b/app/mm-redux/actions/channels.ts index bd816070b..e47b078d9 100644 --- a/app/mm-redux/actions/channels.ts +++ b/app/mm-redux/actions/channels.ts @@ -1475,6 +1475,26 @@ export function patchChannelModerations(channelId: string, patch: Array { + let channelMemberCountsByGroup; + try { + channelMemberCountsByGroup = await Client4.getChannelMemberCountsByGroup(channelId, includeTimezones); + } catch (error) { + return {error}; + } + + if (channelMemberCountsByGroup.length) { + dispatch({ + type: ChannelTypes.RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP, + data: {channelId, memberCounts: channelMemberCountsByGroup}, + }); + } + + return {data: true}; + }; +} + export default { selectChannel, createChannel, diff --git a/app/mm-redux/client/client4.ts b/app/mm-redux/client/client4.ts index c65c77f8f..ff1d27a5f 100644 --- a/app/mm-redux/client/client4.ts +++ b/app/mm-redux/client/client4.ts @@ -1554,6 +1554,13 @@ export default class Client4 { ); }; + getChannelMemberCountsByGroup = async (channelId: string, includeTimezones: boolean) => { + return this.doFetch( + `${this.getChannelRoute(channelId)}/member_counts_by_group?include_timezones=${includeTimezones}`, + {method: 'get'}, + ); + }; + viewMyChannel = async (channelId: string, prevChannelId?: string) => { const data = {channel_id: channelId, prev_channel_id: prevChannelId}; return this.doFetch( diff --git a/app/mm-redux/reducers/entities/channels.test.js b/app/mm-redux/reducers/entities/channels.test.js index e6d945fd4..973a9cf34 100644 --- a/app/mm-redux/reducers/entities/channels.test.js +++ b/app/mm-redux/reducers/entities/channels.test.js @@ -29,6 +29,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -66,6 +67,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -101,6 +103,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -137,6 +140,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -171,6 +175,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -209,6 +214,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -244,6 +250,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -282,6 +289,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -316,6 +324,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -349,6 +358,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -381,6 +391,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -459,6 +470,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -504,6 +516,7 @@ describe('channels', () => { }, }, channelModerations: {}, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -550,6 +563,7 @@ describe('channels', () => { }, }], }, + channelMemberCountsByGroup: {}, }); const nextState = channelsReducer(state, { @@ -576,4 +590,121 @@ describe('channels', () => { expect(nextState.channelModerations.channel1[0].roles.guests).toEqual(false); }); }); + + describe('RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP', () => { + test('Should add new channel member counts', () => { + const state = deepFreeze({ + channelsInTeam: {}, + currentChannelId: '', + groupsAssociatedToChannel: {}, + myMembers: {}, + stats: {}, + totalCount: 0, + membersInChannel: {}, + channels: { + channel1: { + id: 'channel1', + team_id: 'team', + }, + }, + channelModerations: {}, + channelMemberCountsByGroup: {}, + }); + + const nextState = channelsReducer(state, { + type: ChannelTypes.RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP, + sync: true, + currentChannelId: 'channel1', + teamId: 'team', + data: { + channelId: 'channel1', + memberCounts: [ + { + group_id: 'group-1', + channel_member_count: 1, + channel_member_timezones_count: 1, + }, + { + group_id: 'group-2', + channel_member_count: 999, + channel_member_timezones_count: 131, + }, + ], + }, + }); + + expect(nextState.channelMemberCountsByGroup.channel1['group-1'].channel_member_count).toEqual(1); + expect(nextState.channelMemberCountsByGroup.channel1['group-1'].channel_member_timezones_count).toEqual(1); + + expect(nextState.channelMemberCountsByGroup.channel1['group-2'].channel_member_count).toEqual(999); + expect(nextState.channelMemberCountsByGroup.channel1['group-2'].channel_member_timezones_count).toEqual(131); + }); + + test('Should replace existing channel member counts', () => { + const state = deepFreeze({ + channelsInTeam: {}, + currentChannelId: '', + groupsAssociatedToChannel: {}, + myMembers: {}, + stats: {}, + totalCount: 0, + membersInChannel: {}, + channels: { + channel1: { + id: 'channel1', + team_id: 'team', + }, + }, + channelModerations: {}, + channelMemberCountsByGroup: { + 'group-1': { + group_id: 'group-1', + channel_member_count: 1, + channel_member_timezones_count: 1, + }, + 'group-2': { + group_id: 'group-2', + channel_member_count: 999, + channel_member_timezones_count: 131, + }, + }, + }); + + const nextState = channelsReducer(state, { + type: ChannelTypes.RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP, + sync: true, + currentChannelId: 'channel1', + teamId: 'team', + data: { + channelId: 'channel1', + memberCounts: [ + { + group_id: 'group-1', + channel_member_count: 5, + channel_member_timezones_count: 2, + }, + { + group_id: 'group-2', + channel_member_count: 1002, + channel_member_timezones_count: 133, + }, + { + group_id: 'group-3', + channel_member_count: 12, + channel_member_timezones_count: 13, + }, + ], + }, + }); + + expect(nextState.channelMemberCountsByGroup.channel1['group-1'].channel_member_count).toEqual(5); + expect(nextState.channelMemberCountsByGroup.channel1['group-1'].channel_member_timezones_count).toEqual(2); + + expect(nextState.channelMemberCountsByGroup.channel1['group-2'].channel_member_count).toEqual(1002); + expect(nextState.channelMemberCountsByGroup.channel1['group-2'].channel_member_timezones_count).toEqual(133); + + expect(nextState.channelMemberCountsByGroup.channel1['group-3'].channel_member_count).toEqual(12); + expect(nextState.channelMemberCountsByGroup.channel1['group-3'].channel_member_timezones_count).toEqual(13); + }); + }); }); diff --git a/app/mm-redux/reducers/entities/channels.ts b/app/mm-redux/reducers/entities/channels.ts index fb2e8ed65..49b2edb4a 100644 --- a/app/mm-redux/reducers/entities/channels.ts +++ b/app/mm-redux/reducers/entities/channels.ts @@ -4,7 +4,7 @@ import {combineReducers} from 'redux'; import {ChannelTypes, UserTypes, SchemeTypes, GroupTypes} from '@mm-redux/action_types'; import {General} from '../../constants'; import {GenericAction} from '@mm-redux/types/actions'; -import {Channel, ChannelMembership, ChannelStats} from '@mm-redux/types/channels'; +import {Channel, ChannelMembership, ChannelStats, ChannelMemberCountByGroup, ChannelMemberCountsByGroup} from '@mm-redux/types/channels'; import {RelationOneToMany, RelationOneToOne, IDMappedObjects, UserIDMappedObjects} from '@mm-redux/types/utilities'; import {Team} from '@mm-redux/types/teams'; @@ -664,6 +664,33 @@ export function channelModerations(state: any = {}, action: GenericAction) { } } +export function channelMemberCountsByGroup(state: any = {}, action: GenericAction) { + switch (action.type) { + case ChannelTypes.RECEIVED_CHANNEL_MEMBER_COUNTS_BY_GROUP: { + const {channelId, memberCounts} = action.data; + const memberCountsByGroup: ChannelMemberCountsByGroup = {}; + memberCounts.forEach((channelMemberCount: ChannelMemberCountByGroup) => { + if (!state[channelId]?.[channelMemberCount.group_id] || + state[channelId]?.[channelMemberCount.group_id]?.channel_member_count !== channelMemberCount.channel_member_count || + state[channelId]?.[channelMemberCount.group_id]?.channel_member_timezones_count !== channelMemberCount.channel_member_timezones_count) { + memberCountsByGroup[channelMemberCount.group_id] = channelMemberCount; + } + }); + + if (Object.keys(memberCountsByGroup).length > 0) { + return { + ...state, + [channelId]: memberCountsByGroup, + }; + } + + return state; + } + default: + return state; + } +} + export default combineReducers({ // the current selected channel @@ -693,4 +720,7 @@ export default combineReducers({ // object where every key is the channel id and has an object with the channel moderations channelModerations, + + // object where every key is the channel id containing one or several object(s) with a mapping of + channelMemberCountsByGroup, }); diff --git a/app/mm-redux/selectors/entities/channels.test.js b/app/mm-redux/selectors/entities/channels.test.js index be2b474ed..678ea3bab 100644 --- a/app/mm-redux/selectors/entities/channels.test.js +++ b/app/mm-redux/selectors/entities/channels.test.js @@ -3811,3 +3811,32 @@ test('Selectors.Channels.getChannelModerations', () => { assert.equal(Selectors.getChannelModerations(state, undefined), undefined); assert.equal(Selectors.getChannelModerations(state, 'undefined'), undefined); }); + +test('Selectors.Channels.getChannelMemberCountsByGroup', () => { + const memberCounts = { + 'group-1': { + group_id: 'group-1', + channel_member_count: 1, + channel_member_timezones_count: 1, + }, + 'group-2': { + group_id: 'group-2', + channel_member_count: 999, + channel_member_timezones_count: 131, + }, + }; + + const state = { + entities: { + channels: { + channelMemberCountsByGroup: { + channel1: memberCounts, + }, + }, + }, + }; + + assert.deepEqual(Selectors.getChannelMemberCountsByGroup(state, 'channel1'), memberCounts); + assert.deepEqual(Selectors.getChannelMemberCountsByGroup(state, undefined), {}); + assert.deepEqual(Selectors.getChannelMemberCountsByGroup(state, 'undefined'), {}); +}); diff --git a/app/mm-redux/selectors/entities/channels.ts b/app/mm-redux/selectors/entities/channels.ts index 8708080bb..a09fa820e 100644 --- a/app/mm-redux/selectors/entities/channels.ts +++ b/app/mm-redux/selectors/entities/channels.ts @@ -14,7 +14,7 @@ import {createIdsSelector} from '@mm-redux/utils/helpers'; export {getCurrentChannelId, getMyChannelMemberships, getMyCurrentChannelMembership}; import {GlobalState} from '@mm-redux/types/store'; -import {Channel, ChannelStats, ChannelMembership, ChannelModeration} from '@mm-redux/types/channels'; +import {Channel, ChannelStats, ChannelMembership, ChannelModeration, ChannelMemberCountsByGroup} from '@mm-redux/types/channels'; import {UsersState, UserProfile} from '@mm-redux/types/users'; import {PreferenceType} from '@mm-redux/types/preferences'; import {Post} from '@mm-redux/types/posts'; @@ -930,3 +930,7 @@ export function isManuallyUnread(state: GlobalState, channelId?: string): boolea export function getChannelModerations(state: GlobalState, channelId: string): Array { return state.entities.channels.channelModerations[channelId]; } + +export function getChannelMemberCountsByGroup(state: GlobalState, channelId: string): ChannelMemberCountsByGroup { + return state.entities.channels.channelMemberCountsByGroup[channelId] || {}; +} diff --git a/app/mm-redux/selectors/entities/groups.ts b/app/mm-redux/selectors/entities/groups.ts index aa4d6fd01..0f3fb74f6 100644 --- a/app/mm-redux/selectors/entities/groups.ts +++ b/app/mm-redux/selectors/entities/groups.ts @@ -91,6 +91,13 @@ export function getAssociatedGroupsForReference(state: GlobalState, teamId: stri return groupsForReference.sort((groupA: Group, groupB: Group) => groupA.name.localeCompare(groupB.name, locale)); } +export const getAssociatedGroupsForReferenceMap = reselect.createSelector( + getAssociatedGroupsForReference, + (allGroups) => { + return new Map(allGroups.map((group) => [`@${group.name}`, group])); + }, +); + const teamGroupIDs = (state: GlobalState, teamID: string) => state.entities.teams.groupsAssociatedToTeam[teamID]?.ids || []; const channelGroupIDs = (state: GlobalState, channelID: string) => state.entities.channels.groupsAssociatedToChannel[channelID]?.ids || []; diff --git a/app/mm-redux/types/channels.ts b/app/mm-redux/types/channels.ts index d72489b22..5403b88db 100644 --- a/app/mm-redux/types/channels.ts +++ b/app/mm-redux/types/channels.ts @@ -75,6 +75,7 @@ export type ChannelsState = { totalCount: number; manuallyUnread: RelationOneToOne; channelModerations: RelationOneToOne>; + channelMemberCountsByGroup: RelationOneToOne; }; export type ChannelModeration = { @@ -98,3 +99,11 @@ export type ChannelModerationPatch = { members?: boolean; }; }; + +export type ChannelMemberCountByGroup = { + group_id: string; + channel_member_count: number; + channel_member_timezones_count: number; +}; + +export type ChannelMemberCountsByGroup = Record; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 2e9bf0c28..2bcc8e904 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -379,6 +379,11 @@ "mobile.post_textbox.entire_channel.message": "By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?", "mobile.post_textbox.entire_channel.message.with_timezones": "By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?", "mobile.post_textbox.entire_channel.title": "Confirm sending notifications to entire channel", + "mobile.post_textbox.groups.title": "Confirm sending notifications to groups", + "mobile.post_textbox.multi_group.message.with_timezones": "By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?", + "mobile.post_textbox.multi_group.message.without_timezones": "By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people. Are you sure you want to do this?", + "mobile.post_textbox.one_group.message.with_timezones": "By using {mention} 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?", + "mobile.post_textbox.one_group.message.without_timezones": "By using {mention} you are about to send notifications to {totalMembers} people. Are you sure you want to do this?", "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",