[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 <nahumhbl@gmail.com>

* Update app/mm-redux/selectors/entities/channels.ts

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Update app/mm-redux/selectors/entities/channels.test.js

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Update app/constants/autocomplete.js

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* 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 <nahumhbl@gmail.com>

* 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 <nahumhbl@gmail.com>

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Hossein Ahmadian-Yazdi 2020-07-17 12:30:43 -04:00 committed by GitHub
parent 7c41c83196
commit fc815adaeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 828 additions and 21 deletions

View file

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

View file

@ -37,14 +37,20 @@ describe('mapStateToProps', () => {
serverVersion: '',
},
users: {
profiles: {},
currentUserId: '',
},
channels: {
currentChannelId: '',
channelMemberCountsByGroup: {},
channels: {},
},
preferences: {
myPreferences: {},
},
teams: {
teams: {},
},
},
views: {
channel: {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1475,6 +1475,26 @@ export function patchChannelModerations(channelId: string, patch: Array<ChannelM
});
}
export function getChannelMemberCountsByGroup(channelId: string, includeTimezones: boolean): ActionFunc {
return async (dispatch: DispatchFunc) => {
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,

View file

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

View file

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

View file

@ -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 <group_id: ChannelMemberCountByGroup>
channelMemberCountsByGroup,
});

View file

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

View file

@ -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<ChannelModeration> {
return state.entities.channels.channelModerations[channelId];
}
export function getChannelMemberCountsByGroup(state: GlobalState, channelId: string): ChannelMemberCountsByGroup {
return state.entities.channels.channelMemberCountsByGroup[channelId] || {};
}

View file

@ -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 || [];

View file

@ -75,6 +75,7 @@ export type ChannelsState = {
totalCount: number;
manuallyUnread: RelationOneToOne<Channel, boolean>;
channelModerations: RelationOneToOne<Channel, Array<ChannelModeration>>;
channelMemberCountsByGroup: RelationOneToOne<Channel, ChannelMemberCountsByGroup>;
};
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<string, ChannelMemberCountByGroup>;

View file

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