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: '@channel',
+ 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",