From 6fd894953ccc48f44243cd19c52f2fb22f258172 Mon Sep 17 00:00:00 2001 From: Scott Bishel Date: Tue, 25 Jun 2024 12:25:28 -0600 Subject: [PATCH] update for adding multiple members (#25128) * update for adding multiple members * fix unit test * more test fixes * add another unit test * fix object passed by client4 * revert package-lock.json * revert package-lock.json * add length check * limit size of lists in API requests * revert package-lock * add batching to front end * add batching to front end * fix bad merge * update return type * remove unnecessary permisssion check, add unit test * fixes and add tests from review * revert changes adding limits to other apis * fixes * clean-up from code review * fix unit test call * revert back to interface{}, fix unit test --------- Co-authored-by: Mattermost Build --- api/v4/source/channels.yaml | 23 +- server/channels/api4/channel.go | 210 +++++++++++------- server/channels/api4/channel_test.go | 37 +++ server/public/model/client4.go | 17 ++ .../src/actions/channel_actions.test.ts | 8 +- .../channels/src/actions/channel_actions.ts | 12 +- .../src/actions/channels.test.ts | 84 +++++++ .../mattermost-redux/src/actions/channels.ts | 40 ++++ .../src/reducers/entities/channels.test.js | 30 +++ .../src/reducers/entities/channels.ts | 3 +- .../src/reducers/entities/users.test.ts | 28 +++ .../src/reducers/entities/users.ts | 9 + webapp/platform/client/src/client4.ts | 10 + 13 files changed, 408 insertions(+), 103 deletions(-) diff --git a/api/v4/source/channels.yaml b/api/v4/source/channels.yaml index b01d9b8a6c..5641c171db 100644 --- a/api/v4/source/channels.yaml +++ b/api/v4/source/channels.yaml @@ -180,7 +180,9 @@ type: array items: type: string - description: User ids to be in the group message channel + minItems: 3 + maxItems: 8 + description: User ids to be in the group message channel. required: true responses: "201": @@ -408,7 +410,7 @@ type: array items: type: string - description: List of channel ids + description: List of channel ids. required: true responses: "200": @@ -1329,8 +1331,8 @@ post: tags: - channels - summary: Add user to channel - description: Add a user to a channel by creating a channel member object. + summary: Add user(s) to channel + description: Add a user(s) to a channel by creating a channel member object(s). operationId: AddChannelMember parameters: - name: channel_id @@ -1344,12 +1346,17 @@ application/json: schema: type: object - required: - - user_id properties: user_id: type: string - description: The ID of user to add into the channel + description: The ID of user to add into the channel, for backwards compatibility. + user_ids: + type: array + items: + type: string + minItems: 1 + maxItems: 1000 + description: The IDs of users to add into the channel, required if 'user_id' doess not exist. post_root_id: type: string description: The ID of root post where link to add channel member @@ -1392,7 +1399,7 @@ type: array items: type: string - description: List of user ids + description: List of user ids. required: true responses: "200": diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index f2ef036c68..1a9e0e7e3b 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -16,6 +16,8 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/audit" ) +const maxListSize = 1000 + func (api *API) InitChannel() { api.BaseRoutes.Channels.Handle("", api.APISessionRequired(getAllChannels)).Methods("GET") api.BaseRoutes.Channels.Handle("", api.APISessionRequired(createChannel)).Methods("POST") @@ -1725,43 +1727,47 @@ func addChannelMember(c *Context, w http.ResponseWriter, r *http.Request) { } props := model.StringInterfaceFromJSON(r.Body) - userId, ok := props["user_id"].(string) - if !ok || !model.IsValidId(userId) { - c.SetInvalidParam("user_id") - return - } - auditRec := c.MakeAuditRecord("addChannelMember", audit.Fail) - defer c.LogAuditRec(auditRec) - audit.AddEventParameter(auditRec, "user_id", userId) - audit.AddEventParameter(auditRec, "channel_id", c.Params.ChannelId) - - member := &model.ChannelMember{ - ChannelId: c.Params.ChannelId, - UserId: userId, + var userIds []string + interfaceIds, ok := props["user_ids"].([]interface{}) + if ok { + if len(interfaceIds) > maxListSize { + c.SetInvalidParam("user_ids") + return + } + for _, userId := range interfaceIds { + userIds = append(userIds, userId.(string)) + } + } else { + userId, ok2 := props["user_id"].(string) + if !ok2 || !model.IsValidId(userId) { + c.SetInvalidParam("user_id or user_ids") + return + } + userIds = append(userIds, userId) } postRootId, ok := props["post_root_id"].(string) - if ok && postRootId != "" && !model.IsValidId(postRootId) { - c.SetInvalidParam("post_root_id") - return - } + if ok && postRootId != "" { + if !model.IsValidId(postRootId) { + c.SetInvalidParam("post_root_id") + return + } - audit.AddEventParameter(auditRec, "post_root_id", postRootId) - - if ok && len(postRootId) == 26 { rootPost, err := c.App.GetSinglePost(c.AppContext, postRootId, false) if err != nil { c.Err = err return } - if rootPost.ChannelId != member.ChannelId { + if rootPost.ChannelId != c.Params.ChannelId { c.SetInvalidParam("post_root_id") return } + } else if !ok { + postRootId = "" } - channel, err := c.App.GetChannel(c.AppContext, member.ChannelId) + channel, err := c.App.GetChannel(c.AppContext, c.Params.ChannelId) if err != nil { c.Err = err return @@ -1772,54 +1778,28 @@ func addChannelMember(c *Context, w http.ResponseWriter, r *http.Request) { return } - isNewMembership := false - if _, err = c.App.GetChannelMember(c.AppContext, member.ChannelId, member.UserId); err != nil { - if err.Id == app.MissingChannelMemberError { - isNewMembership = true - } else { - c.Err = err - return - } - } - - isSelfAdd := member.UserId == c.AppContext.Session().UserId - + canAddSelf := false + canAddOthers := false if channel.Type == model.ChannelTypeOpen { - if isSelfAdd && isNewMembership { - if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionJoinPublicChannels) { - c.SetPermissionError(model.PermissionJoinPublicChannels) - return - } - } else if isSelfAdd && !isNewMembership { - // nothing to do, since already in the channel - } else if !isSelfAdd { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePublicChannelMembers) { - c.SetPermissionError(model.PermissionManagePublicChannelMembers) - return - } + if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionJoinPublicChannels) { + canAddSelf = true + } + if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePublicChannelMembers) { + canAddOthers = true } } if channel.Type == model.ChannelTypePrivate { - if isSelfAdd && isNewMembership { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers) { - c.SetPermissionError(model.PermissionManagePrivateChannelMembers) - return - } - } else if isSelfAdd && !isNewMembership { - // nothing to do, since already in the channel - } else if !isSelfAdd { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers) { - c.SetPermissionError(model.PermissionManagePrivateChannelMembers) - return - } + if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers) { + c.SetPermissionError(model.PermissionManagePrivateChannelMembers) + return } } if channel.IsGroupConstrained() { - nonMembers, err := c.App.FilterNonGroupChannelMembers([]string{member.UserId}, channel) + nonMembers, err := c.App.FilterNonGroupChannelMembers(userIds, channel) if err != nil { - if v, ok := err.(*model.AppError); ok { + if v, ok2 := err.(*model.AppError); ok2 { c.Err = v } else { c.Err = model.NewAppError("addChannelMember", "api.channel.add_members.error", nil, "", http.StatusBadRequest).Wrap(err) @@ -1832,32 +1812,98 @@ func addChannelMember(c *Context, w http.ResponseWriter, r *http.Request) { } } - cm, err := c.App.AddChannelMember(c.AppContext, member.UserId, channel, app.ChannelMemberOpts{ - UserRequestorID: c.AppContext.Session().UserId, - PostRootID: postRootId, - }) - if err != nil { - c.Err = err + var lastError *model.AppError + var newChannelMembers []model.ChannelMember + for _, userId := range userIds { + if !model.IsValidId(userId) { + c.Logger.Warn("Error adding channel member, invalid UserId", mlog.String("UserId", userId), mlog.String("ChannelId", channel.Id)) + c.SetInvalidParam("user_id") + lastError = c.Err + continue + } + + auditRec := c.MakeAuditRecord("addChannelMember", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "user_id", userId) + audit.AddEventParameter(auditRec, "channel_id", c.Params.ChannelId) + audit.AddEventParameter(auditRec, "post_root_id", postRootId) + + member := &model.ChannelMember{ + ChannelId: c.Params.ChannelId, + UserId: userId, + } + + existingMember, err := c.App.GetChannelMember(c.AppContext, member.ChannelId, member.UserId) + if err != nil { + if err.Id != app.MissingChannelMemberError { + c.Logger.Warn("Error adding channel member, error getting channel member", mlog.String("UserId", userId), mlog.String("ChannelId", channel.Id), mlog.Err(err)) + lastError = err + continue + } + } else { + // user is already a member, go to next + c.Logger.Warn("User is already a channel member, skipping", mlog.String("UserId", userId), mlog.String("ChannelId", channel.Id)) + newChannelMembers = append(newChannelMembers, *existingMember) + continue + } + + if channel.Type == model.ChannelTypeOpen { + isSelfAdd := member.UserId == c.AppContext.Session().UserId + if isSelfAdd && !canAddSelf { + c.Logger.Warn("Error adding channel member, Invalid Permission to add self", mlog.String("UserId", userId), mlog.String("ChannelId", channel.Id)) + c.SetPermissionError(model.PermissionJoinPublicChannels) + lastError = c.Err + continue + } else if !isSelfAdd && !canAddOthers { + c.Logger.Warn("Error adding channel member, Invalid Permission to add others", mlog.String("UserId", userId), mlog.String("ChannelId", channel.Id)) + c.SetPermissionError(model.PermissionManagePublicChannelMembers) + lastError = c.Err + continue + } + } + + cm, err := c.App.AddChannelMember(c.AppContext, member.UserId, channel, app.ChannelMemberOpts{ + UserRequestorID: c.AppContext.Session().UserId, + PostRootID: postRootId, + }) + if err != nil { + c.Logger.Warn("Error adding channel member", mlog.String("UserId", userId), mlog.String("ChannelId", channel.Id), mlog.Err(err)) + lastError = err + continue + } + newChannelMembers = append(newChannelMembers, *cm) + + if postRootId != "" { + err := c.App.UpdateThreadFollowForUserFromChannelAdd(c.AppContext, cm.UserId, channel.TeamId, postRootId) + if err != nil { + c.Logger.Warn("Error adding channel member, error updating thread", mlog.String("UserId", userId), mlog.String("ChannelId", channel.Id), mlog.Err(err)) + lastError = err + continue + } + } + + auditRec.Success() + auditRec.AddEventResultState(cm) + auditRec.AddEventObjectType("channel_member") + auditRec.AddMeta("add_user_id", cm.UserId) + c.LogAudit("name=" + channel.Name + " user_id=" + cm.UserId) + } + + if lastError != nil && len(newChannelMembers) == 0 { + c.Err = lastError return } - if postRootId != "" { - err := c.App.UpdateThreadFollowForUserFromChannelAdd(c.AppContext, cm.UserId, channel.TeamId, postRootId) - if err != nil { - c.Err = err - return - } - } - - auditRec.Success() - auditRec.AddEventResultState(cm) - auditRec.AddEventObjectType("channel_member") - auditRec.AddMeta("add_user_id", cm.UserId) - c.LogAudit("name=" + channel.Name + " user_id=" + cm.UserId) - w.WriteHeader(http.StatusCreated) - if err := json.NewEncoder(w).Encode(cm); err != nil { - c.Logger.Warn("Error while writing response", mlog.Err(err)) + userId, ok := props["user_id"] + if ok && len(newChannelMembers) == 1 && newChannelMembers[0].UserId == userId { + if err := json.NewEncoder(w).Encode(newChannelMembers[0]); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } + } else { + if err := json.NewEncoder(w).Encode(newChannelMembers); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } } } diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index 64140e2466..9d6304e709 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -3294,6 +3294,36 @@ func TestAddChannelMember(t *testing.T) { }) } +func TestAddChannelMembers(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + client := th.Client + user := th.BasicUser + user2 := th.BasicUser2 + team := th.BasicTeam + publicChannel := th.CreatePublicChannel() + privateChannel := th.CreatePrivateChannel() + + user3 := th.CreateUserWithClient(th.SystemAdminClient) + _, _, err := th.SystemAdminClient.AddTeamMember(context.Background(), team.Id, user3.Id) + require.NoError(t, err) + + cm, resp, err := client.AddChannelMembers(context.Background(), publicChannel.Id, "", []string{user.Id, user2.Id, user3.Id}) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.Equal(t, publicChannel.Id, cm[0].ChannelId, "should have returned exact channel") + require.Equal(t, user.Id, cm[0].UserId, "should have returned exact user added to public channel") + require.Equal(t, user2.Id, cm[1].UserId, "should have returned exact user added to public channel") + require.Equal(t, user3.Id, cm[2].UserId, "should have returned exact user added to public channel") + + cm, _, err = client.AddChannelMembers(context.Background(), privateChannel.Id, "", []string{user.Id, user2.Id, user3.Id}) + require.NoError(t, err) + require.Equal(t, privateChannel.Id, cm[0].ChannelId, "should have returned exact channel") + require.Equal(t, user.Id, cm[0].UserId, "should have returned exact user added to public channel") + require.Equal(t, user2.Id, cm[1].UserId, "should have returned exact user added to public channel") + require.Equal(t, user3.Id, cm[2].UserId, "should have returned exact user added to public channel") +} + func TestAddChannelMemberFromThread(t *testing.T) { t.Skip("MM-41285") th := Setup(t).InitBasic() @@ -4784,6 +4814,13 @@ func TestGetChannelsMemberCount(t *testing.T) { _, _, err := client.GetChannelsMemberCount(context.Background(), channelIDs) require.NoError(t, err) }) + + t.Run("Should fail for private channels that the user is not a member of", func(t *testing.T) { + th.LoginBasic2() + channelIDs := []string{channel2.Id} + _, _, err := client.GetChannelsMemberCount(context.Background(), channelIDs) + require.Error(t, err) + }) } func TestMoveChannel(t *testing.T) { diff --git a/server/public/model/client4.go b/server/public/model/client4.go index e1e5f8a978..71a53ff6b6 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -3730,6 +3730,23 @@ func (c *Client4) AddChannelMember(ctx context.Context, channelId, userId string return ch, BuildResponse(r), nil } +// AddChannelMembers adds users to a channel and return an array of channel members. +func (c *Client4) AddChannelMembers(ctx context.Context, channelId, postRootId string, userIds []string) ([]*ChannelMember, *Response, error) { + requestBody := map[string]any{"user_ids": userIds, "post_root_id": postRootId} + r, err := c.DoAPIPost(ctx, c.channelMembersRoute(channelId)+"", StringInterfaceToJSON(requestBody)) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var ch []*ChannelMember + err = json.NewDecoder(r.Body).Decode(&ch) + if err != nil { + return nil, BuildResponse(r), NewAppError("AddChannelMembers", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return ch, BuildResponse(r), nil +} + // AddChannelMemberWithRootId adds user to channel and return a channel member. Post add to channel message has the postRootId. func (c *Client4) AddChannelMemberWithRootId(ctx context.Context, channelId, userId, postRootId string) (*ChannelMember, *Response, error) { requestBody := map[string]string{"user_id": userId, "post_root_id": postRootId} diff --git a/webapp/channels/src/actions/channel_actions.test.ts b/webapp/channels/src/actions/channel_actions.test.ts index 1e9c656253..c98ad51b24 100644 --- a/webapp/channels/src/actions/channel_actions.test.ts +++ b/webapp/channels/src/actions/channel_actions.test.ts @@ -117,7 +117,7 @@ jest.mock('mattermost-redux/actions/channels', () => ({ }], }; }, - addChannelMember: (...args: any) => ({type: 'MOCK_ADD_CHANNEL_MEMBER', args}), + addChannelMembers: (...args: any) => ({type: 'MOCK_ADD_CHANNEL_MEMBERS', args}), createDirectChannel: (...args: any) => ({type: 'MOCK_CREATE_DIRECT_CHANNEL', args}), createGroupChannel: (...args: any) => ({type: 'MOCK_CREATE_GROUP_CHANNEL', args}), })); @@ -146,13 +146,13 @@ describe('Actions.Channel', () => { const testStore = await mockStore(initialState); const expectedActions = [{ - type: 'MOCK_ADD_CHANNEL_MEMBER', - args: ['testid', 'testuserid'], + type: 'MOCK_ADD_CHANNEL_MEMBERS', + args: ['testid', ['testuserid', 'testuserid2']], }]; const fakeData = { channel: 'testid', - userIds: ['testuserid'], + userIds: ['testuserid', 'testuserid2'], }; await testStore.dispatch(addUsersToChannel(fakeData.channel, fakeData.userIds)); diff --git a/webapp/channels/src/actions/channel_actions.ts b/webapp/channels/src/actions/channel_actions.ts index be8360189f..1dca18ef12 100644 --- a/webapp/channels/src/actions/channel_actions.ts +++ b/webapp/channels/src/actions/channel_actions.ts @@ -155,15 +155,11 @@ export function autocompleteChannelsForSearch(term: string, success?: (channels: export function addUsersToChannel(channelId: Channel['id'], userIds: Array): ActionFuncAsync { return async (dispatch) => { - try { - const requests = userIds.map((uId) => dispatch(ChannelActions.addChannelMember(channelId, uId))); - - await Promise.all(requests); - - return {data: true}; - } catch (error) { - return {error}; + const error = await dispatch(ChannelActions.addChannelMembers(channelId, userIds)); + if (error) { + return error; } + return {data: true}; }; } diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.test.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.test.ts index 8724bc1bb7..f86e4514cc 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.test.ts @@ -1347,6 +1347,90 @@ describe('Actions.Channels', () => { expect(stats[channelId].member_count >= 2).toBeTruthy(); }); + it('addChannelMembers', async () => { + const channelId = TestHelper.basicChannel!.id; + + nock(Client4.getBaseRoute()). + post(`/channels/${TestHelper.basicChannel!.id}/members`). + reply(201, {channel_id: TestHelper.basicChannel!.id, roles: 'channel_user', user_id: TestHelper.basicUser!.id}); + + await store.dispatch(Actions.joinChannel(TestHelper.basicUser!.id, TestHelper.basicTeam!.id, channelId)); + + nock(Client4.getBaseRoute()). + get(`/channels/${TestHelper.basicChannel!.id}/stats?exclude_files_count=true`). + reply(200, {channel_id: TestHelper.basicChannel!.id, member_count: 1}); + + await store.dispatch(Actions.getChannelStats(channelId)); + + let state = store.getState(); + let {stats} = state.entities.channels; + expect(stats).toBeTruthy(); + + // stats for channel + expect(stats[channelId]).toBeTruthy(); + + // member count for channel + expect(stats[channelId].member_count).toBeTruthy(); + + // incorrect member count for channel + expect(stats[channelId].member_count >= 1).toBeTruthy(); + + nock(Client4.getBaseRoute()). + post('/users'). + query(true). + reply(201, TestHelper.fakeUserWithId()); + const user = await TestHelper.basicClient4!.createUser( + TestHelper.fakeUser(), + '', + '', + TestHelper.basicTeam!.invite_id, + ); + + nock(Client4.getBaseRoute()). + post('/users'). + query(true). + reply(201, TestHelper.fakeUserWithId()); + const user2 = await TestHelper.basicClient4!.createUser( + TestHelper.fakeUser(), + '', + '', + TestHelper.basicTeam!.invite_id, + ); + + nock(Client4.getBaseRoute()). + post(`/channels/${TestHelper.basicChannel!.id}/members`). + reply(201, [{channel_id: TestHelper.basicChannel!.id, roles: 'channel_user', user_id: user.id}, + {channel_id: TestHelper.basicChannel!.id, roles: 'channel_user', user_id: user2.id}]); + + await store.dispatch(Actions.addChannelMembers(channelId, [user.id, user2.id])); + + state = store.getState(); + + const {profilesInChannel, profilesNotInChannel} = state.entities.users; + const channel = profilesInChannel[channelId]; + const notChannel = profilesNotInChannel[channelId]; + expect(channel).toBeTruthy(); + expect(notChannel).toBeTruthy(); + expect(channel.has(user.id)).toBeTruthy(); + expect(channel.has(user2.id)).toBeTruthy(); + + // user should not present in profilesNotInChannel + expect(notChannel.has(user.id)).toEqual(false); + expect(notChannel.has(user2.id)).toEqual(false); + + stats = state.entities.channels.stats; + expect(stats).toBeTruthy(); + + // stats for channel + expect(stats[channelId]).toBeTruthy(); + + // member count for channel + expect(stats[channelId].member_count).toBeTruthy(); + + // incorrect member count for channel + expect(stats[channelId].member_count >= 3).toBeTruthy(); + }); + it('removeChannelMember', async () => { const channelId = TestHelper.basicChannel!.id; diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts index e42076b6cb..ad550537a3 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -1048,6 +1048,45 @@ export function addChannelMember(channelId: string, userId: string, postRootId = }; } +export function addChannelMembers(channelId: string, userIds: string[], postRootId = ''): ActionFuncAsync { + const batchSize = 1000; + return async (dispatch, getState) => { + const channelMembers: ChannelMembership[] = []; + try { + for (let i = 0; i < userIds.length; i += batchSize) { + // eslint-disable-next-line no-await-in-loop + const cm = await Client4.addToChannels(userIds.slice(i, i + batchSize), channelId, postRootId); + channelMembers.push(...cm); + } + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + Client4.trackEvent('action', 'action_channels_add_member', {channel_id: channelId}); + + const ids = channelMembers.map((member) => ({id: member.user_id})); + dispatch(batchActions([ + { + type: UserTypes.RECEIVED_PROFILES_IN_CHANNEL, + id: channelId, + data: ids, + }, + { + type: ChannelTypes.RECEIVED_CHANNEL_MEMBERS, + data: channelMembers, + }, + { + type: ChannelTypes.ADD_CHANNEL_MEMBER_SUCCESS, + id: channelId, + count: channelMembers.length, + }, + ], 'ADD_CHANNEL_MEMBERS.BATCH')); + return {data: channelMembers}; + }; +} + export function removeChannelMember(channelId: string, userId: string): ActionFuncAsync { return async (dispatch, getState) => { try { @@ -1400,6 +1439,7 @@ export default { searchGroupChannels, getChannelStats, addChannelMember, + addChannelMembers, removeChannelMember, markChannelAsRead, favoriteChannel, diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channels.test.js b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channels.test.js index 5db419036b..ccfe659ee6 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channels.test.js +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channels.test.js @@ -135,6 +135,36 @@ describe('channels', () => { }); }); + describe('ADD_CHANNEL_MEMBER_SUCCESS', () => { + const state = deepFreeze(channelsReducer({ + stats: { + channel1: { + id: 'channel1', + member_count: 1, + }, + }, + }, {})); + test('should increment by 1 default', () => { + const nextState = channelsReducer(state, { + type: ChannelTypes.ADD_CHANNEL_MEMBER_SUCCESS, + id: 'channel1', + }); + + expect(nextState).not.toBe(state); + expect(nextState.stats.channel1.member_count).toEqual(2); + }); + test('should increment by number passed', () => { + const nextState = channelsReducer(state, { + type: ChannelTypes.ADD_CHANNEL_MEMBER_SUCCESS, + id: 'channel1', + count: 100, + }); + + expect(nextState).not.toBe(state); + expect(nextState.stats.channel1.member_count).toEqual(101); + }); + }); + describe('REMOVE_MEMBER_FROM_CHANNEL', () => { test('should remove the channel member', () => { const state = deepFreeze(channelsReducer({ diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channels.ts index aaf558daab..9e414b450d 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/channels.ts @@ -592,9 +592,10 @@ function stats(state: RelationOneToOne = {}, action: AnyA case ChannelTypes.ADD_CHANNEL_MEMBER_SUCCESS: { const nextState = {...state}; const id = action.id; + const receivedCount = action.count ? action.count : 1; const nextStat = nextState[id]; if (nextStat) { - const count = nextStat.member_count + 1; + const count = nextStat.member_count + receivedCount; return { ...nextState, [id]: { diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts index 0279d16bf4..05da451780 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.test.ts @@ -304,6 +304,34 @@ describe('Reducers.users', () => { expect(newState.profilesNotInChannel).toEqual(expectedState.profilesNotInChannel); }); + it('UserTypes.RECEIVED_PROFILES_IN_CHANNEL, existing state', () => { + const state = { + profilesNotInChannel: { + id: new Set().add('old_user_id').add('other_user_id'), + }, + }; + const action = { + type: UserTypes.RECEIVED_PROFILES_IN_CHANNEL, + id: 'id', + data: { + old_user_id: { + id: 'old_user_id', + }, + other_user_id: { + id: 'other_user_id', + }, + }, + }; + const expectedState = { + profilesNotInChannel: { + id: new Set(), + }, + }; + + const newState = reducer(state as unknown as ReducerState, action); + expect(newState.profilesNotInChannel).toEqual(expectedState.profilesNotInChannel); + }); + it('UserTypes.RECEIVED_PROFILE_NOT_IN_CHANNEL, no existing profiles', () => { const state = { profilesNotInChannel: {}, diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts index 9c96f3eefd..f414ab1243 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/users.ts @@ -18,6 +18,12 @@ function profilesToSet(state: RelationOneToManyUnique, action return users.reduce((nextState, user) => addProfileToSet(nextState, id, user.id), state); } +function removeProfilesFromSet(state: RelationOneToManyUnique, action: AnyAction) { + const id = action.id; + const users: UserProfile[] = Object.values(action.data); + return users.reduce((nextState, user) => removeProfileFromSet(nextState, {type: '', data: {id, user_id: user.id}}), state); +} + function profileListToSet(state: RelationOneToManyUnique, action: AnyAction, replace = false) { const id = action.id; const users: UserProfile[] = action.data || []; @@ -391,6 +397,9 @@ function profilesNotInChannel(state: UsersState['profilesNotInChannel'] = {}, ac case UserTypes.RECEIVED_PROFILES_NOT_IN_CHANNEL: return profilesToSet(state, action); + case UserTypes.RECEIVED_PROFILES_IN_CHANNEL: + return removeProfilesFromSet(state, action); + case UserTypes.RECEIVED_PROFILE_IN_CHANNEL: return removeProfileFromSet(state, action); diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 719ad4dc79..615ff492bb 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -1811,6 +1811,16 @@ export default class Client4 { ); }; + addToChannels = (userIds: string[], channelId: string, postRootId = '') => { + this.trackEvent('api', 'api_channels_add_members', {channel_id: channelId}); + + const members = {user_ids: userIds, channel_id: channelId, post_root_id: postRootId}; + return this.doFetch( + `${this.getChannelMembersRoute(channelId)}`, + {method: 'post', body: JSON.stringify(members)}, + ); + }; + addToChannel = (userId: string, channelId: string, postRootId = '') => { this.trackEvent('api', 'api_channels_add_member', {channel_id: channelId});