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