diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index dc107fe0f4..12085d7c06 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -3094,14 +3094,62 @@ func getChannelMembersForUser(c *Context, w http.ResponseWriter, r *http.Request return } - members, err := c.App.GetChannelMembersWithTeamDataForUserWithPagination(c.AppContext, c.Params.UserId, c.Params.Page, c.Params.PerPage) - if err != nil { - c.Err = err + // For backward compatibility purposes + if c.Params.Page != -1 { + cursor := &model.ChannelMemberCursor{ + Page: c.Params.Page, + PerPage: c.Params.PerPage, + } + members, err := c.App.GetChannelMembersWithTeamDataForUserWithPagination(c.AppContext, c.Params.UserId, cursor) + if err != nil { + c.Err = err + return + } + + if err := json.NewEncoder(w).Encode(members); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } return } - if err := json.NewEncoder(w).Encode(members); err != nil { - c.Logger.Warn("Error while writing response", mlog.Err(err)) + // The new model streams data using NDJSON format (each JSON object on a new line) + pageSize := 100 + fromChannelID := "" + + // Set the correct content type for NDJSON + w.Header().Set("Content-Type", "application/x-ndjson") + + enc := json.NewEncoder(w) + + for { + cursor := &model.ChannelMemberCursor{ + Page: -1, + PerPage: pageSize, + FromChannelID: fromChannelID, + } + + members, err := c.App.GetChannelMembersWithTeamDataForUserWithPagination(c.AppContext, c.Params.UserId, cursor) + if err != nil { + // If the page size was a perfect multiple of the total number of results, + // then the last query will always return zero results. + if fromChannelID != "" && err.Id == app.MissingChannelMemberError { + break + } + c.Err = err + return + } + + for _, member := range members { + if err := enc.Encode(member); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } + } + + if len(members) < pageSize { + break + } + + fromChannelID = members[len(members)-1].ChannelId } } diff --git a/server/channels/api4/user_test.go b/server/channels/api4/user_test.go index 66c7211001..e531311c42 100644 --- a/server/channels/api4/user_test.go +++ b/server/channels/api4/user_test.go @@ -6664,6 +6664,24 @@ func TestGetChannelMembersWithTeamData(t *testing.T) { for _, ch := range channels { assert.Equal(t, th.BasicTeam.DisplayName, ch.TeamDisplayName) } + + channels, resp, err = th.Client.GetChannelMembersWithTeamData(context.Background(), th.BasicUser.Id, 0, 5000) + require.NoError(t, err) + CheckOKStatus(t, resp) + assert.Len(t, channels, 6) + for _, ch := range channels { + assert.Equal(t, th.BasicTeam.DisplayName, ch.TeamDisplayName) + } + + // perPage doesn't matter if page=-1 + channels, resp, err = th.Client.GetChannelMembersWithTeamData(context.Background(), th.BasicUser.Id, -1, 2) + require.NoError(t, err) + CheckOKStatus(t, resp) + assert.Equal(t, "application/x-ndjson", resp.Header.Get("Content-Type")) + assert.Len(t, channels, 6) + for _, ch := range channels { + assert.Equal(t, th.BasicTeam.DisplayName, ch.TeamDisplayName) + } } func TestMigrateAuthToLDAP(t *testing.T) { diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 93276fbf35..2577e1a899 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -2174,10 +2174,25 @@ func (a *App) GetChannelMembersForUserWithPagination(c request.CTX, userID strin return members, nil } -func (a *App) GetChannelMembersWithTeamDataForUserWithPagination(c request.CTX, userID string, page, perPage int) (model.ChannelMembersWithTeamData, *model.AppError) { - m, err := a.Srv().Store().Channel().GetMembersForUserWithPagination(userID, page, perPage) +func (a *App) GetChannelMembersWithTeamDataForUserWithPagination(c request.CTX, userID string, cursor *model.ChannelMemberCursor) (model.ChannelMembersWithTeamData, *model.AppError) { + var m model.ChannelMembersWithTeamData + var err error + var method string + if cursor.Page == -1 { + m, err = a.Srv().Store().Channel().GetMembersForUserWithCursorPagination(userID, cursor.PerPage, cursor.FromChannelID) + method = "GetMembersForUserWithCursorPagination" + } else { + m, err = a.Srv().Store().Channel().GetMembersForUserWithPagination(userID, cursor.Page, cursor.PerPage) + method = "GetMembersForUserWithPagination" + } if err != nil { - return nil, model.NewAppError("GetChannelMembersForUserWithPagination", "app.channel.get_members.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError(method, MissingChannelMemberError, nil, "", http.StatusNotFound).Wrap(err) + default: + return nil, model.NewAppError(method, "app.channel.get_members.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } } return m, nil diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 7af52085bc..1feea42a13 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -2071,6 +2071,27 @@ func (s *RetryLayerChannelStore) GetMembersForUser(teamID string, userID string) } +func (s *RetryLayerChannelStore) GetMembersForUserWithCursorPagination(userId string, perPage int, fromChanneID string) (model.ChannelMembersWithTeamData, error) { + + tries := 0 + for { + result, err := s.ChannelStore.GetMembersForUserWithCursorPagination(userId, perPage, fromChanneID) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerChannelStore) GetMembersForUserWithPagination(userID string, page int, perPage int) (model.ChannelMembersWithTeamData, error) { tries := 0 diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index eea663f6e6..01f9ef5909 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -3077,6 +3077,20 @@ func (s SqlChannelStore) GetMembersForUserWithPagination(userId string, page, pe return dbMembers.ToModel(), nil } +func (s SqlChannelStore) GetMembersForUserWithCursorPagination(userId string, perPage int, fromChannelID string) (model.ChannelMembersWithTeamData, error) { + dbMembers := channelMemberWithTeamWithSchemeRolesList{} + err := s.GetReplica().Select(&dbMembers, channelMembersWithSchemeSelectQuery+"WHERE ChannelMembers.UserId = ? AND ChannelId > ? ORDER BY ChannelId ASC Limit ?", userId, fromChannelID, perPage) + if err != nil { + return nil, errors.Wrapf(err, "failed to find ChannelMembers data with and userId=%s", userId) + } + + if len(dbMembers) == 0 { + return nil, store.NewErrNotFound("ChannelMembers", "userId="+userId) + } + + return dbMembers.ToModel(), nil +} + func (s SqlChannelStore) GetTeamMembersForChannel(channelID string) ([]string, error) { teamMemberIDs := []string{} if err := s.GetReplica().Select(&teamMemberIDs, `SELECT tm.UserId diff --git a/server/channels/store/store.go b/server/channels/store/store.go index c624face8a..008855c7dc 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -266,6 +266,7 @@ type ChannelStore interface { GetMembersForUser(teamID string, userID string) (model.ChannelMembers, error) GetTeamMembersForChannel(channelID string) ([]string, error) GetMembersForUserWithPagination(userID string, page, perPage int) (model.ChannelMembersWithTeamData, error) + GetMembersForUserWithCursorPagination(userId string, perPage int, fromChanneID string) (model.ChannelMembersWithTeamData, error) Autocomplete(rctx request.CTX, userID, term string, includeDeleted, isGuest bool) (model.ChannelListWithTeamData, error) AutocompleteInTeam(rctx request.CTX, teamID, userID, term string, includeDeleted, isGuest bool) (model.ChannelList, error) AutocompleteInTeamForSearch(teamID string, userID string, term string, includeDeleted bool) (model.ChannelList, error) diff --git a/server/channels/store/storetest/channel_store.go b/server/channels/store/storetest/channel_store.go index 13fca2c0a4..62e65fd3bc 100644 --- a/server/channels/store/storetest/channel_store.go +++ b/server/channels/store/storetest/channel_store.go @@ -107,6 +107,7 @@ func TestChannelStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore t.Run("GetChannelCounts", func(t *testing.T) { testChannelStoreGetChannelCounts(t, rctx, ss) }) t.Run("GetMembersForUser", func(t *testing.T) { testChannelStoreGetMembersForUser(t, rctx, ss) }) t.Run("GetMembersForUserWithPagination", func(t *testing.T) { testChannelStoreGetMembersForUserWithPagination(t, rctx, ss) }) + t.Run("GetMembersForUserWithCursorPagination", func(t *testing.T) { testChannelStoreGetMembersForUserWithCursorPagination(t, rctx, ss) }) t.Run("CountPostsAfter", func(t *testing.T) { testCountPostsAfter(t, rctx, ss) }) t.Run("CountUrgentPostsAfter", func(t *testing.T) { testCountUrgentPostsAfter(t, rctx, ss) }) t.Run("UpdateLastViewedAt", func(t *testing.T) { testChannelStoreUpdateLastViewedAt(t, rctx, ss) }) @@ -4719,6 +4720,93 @@ func testChannelStoreGetMembersForUserWithPagination(t *testing.T, rctx request. assert.Len(t, members, 1) } +func testChannelStoreGetMembersForUserWithCursorPagination(t *testing.T, rctx request.CTX, ss store.Store) { + t1 := model.Team{ + DisplayName: "team1", + Name: NewTestID(), + Email: MakeEmail(), + Type: model.TeamOpen, + } + _, err := ss.Team().Save(&t1) + require.NoError(t, err) + + userID := NewTestID() + + var channelIDs []string + for i := 0; i < 20; i++ { + ch := &model.Channel{ + TeamId: t1.Id, + DisplayName: "Channel1", + Name: NewTestID(), + Type: model.ChannelTypeOpen, + } + ch, err = ss.Channel().Save(rctx, ch, -1) + require.NoError(t, err) + channelIDs = append(channelIDs, ch.Id) + + m1 := model.ChannelMember{} + m1.ChannelId = ch.Id + m1.UserId = userID + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + _, err = ss.Channel().SaveMember(rctx, &m1) + require.NoError(t, err) + } + + members, err := ss.Channel().GetMembersForUserWithCursorPagination(userID, 200, "") + require.NoError(t, err) + assert.Len(t, members, 20) + + pageSize := 6 + channelID := "" + numPages := 0 + var gotChannelIDs []string + for { + members, err := ss.Channel().GetMembersForUserWithCursorPagination(userID, pageSize, channelID) + require.NoError(t, err) + numPages++ + for _, m := range members { + gotChannelIDs = append(gotChannelIDs, m.ChannelId) + } + if len(members) < pageSize { + // Total 20. PageSize=6. Therefore 6*3=18 + 2 (last page) + assert.Len(t, members, 2) + break + } + if len(members) == pageSize { + channelID = members[len(members)-1].ChannelId + continue + } + require.Fail(t, "len(members) is > pageSize") + } + assert.Equal(t, numPages, 4) + assert.ElementsMatch(t, channelIDs, gotChannelIDs) + + pageSize = 5 + channelID = "" + numPages = 0 + gotChannelIDs = []string{} + for { + members, err := ss.Channel().GetMembersForUserWithCursorPagination(userID, pageSize, channelID) + numPages++ + if numPages < 5 { + channelID = members[len(members)-1].ChannelId + require.NoError(t, err) + } else { + // For the last page, it will have no rows. + var nfErr *store.ErrNotFound + require.True(t, errors.As(err, &nfErr)) + require.Nil(t, members) + break + } + for _, m := range members { + gotChannelIDs = append(gotChannelIDs, m.ChannelId) + } + assert.Len(t, members, pageSize) + } + assert.Equal(t, numPages, 5) + assert.ElementsMatch(t, channelIDs, gotChannelIDs) +} + func testCountPostsAfter(t *testing.T, rctx request.CTX, ss store.Store) { t.Run("should count all posts with or without the given user ID", func(t *testing.T) { userID1 := model.NewId() diff --git a/server/channels/store/storetest/mocks/ChannelStore.go b/server/channels/store/storetest/mocks/ChannelStore.go index caca201fa7..275586d5ca 100644 --- a/server/channels/store/storetest/mocks/ChannelStore.go +++ b/server/channels/store/storetest/mocks/ChannelStore.go @@ -1700,6 +1700,36 @@ func (_m *ChannelStore) GetMembersForUser(teamID string, userID string) (model.C return r0, r1 } +// GetMembersForUserWithCursorPagination provides a mock function with given fields: userId, perPage, fromChanneID +func (_m *ChannelStore) GetMembersForUserWithCursorPagination(userId string, perPage int, fromChanneID string) (model.ChannelMembersWithTeamData, error) { + ret := _m.Called(userId, perPage, fromChanneID) + + if len(ret) == 0 { + panic("no return value specified for GetMembersForUserWithCursorPagination") + } + + var r0 model.ChannelMembersWithTeamData + var r1 error + if rf, ok := ret.Get(0).(func(string, int, string) (model.ChannelMembersWithTeamData, error)); ok { + return rf(userId, perPage, fromChanneID) + } + if rf, ok := ret.Get(0).(func(string, int, string) model.ChannelMembersWithTeamData); ok { + r0 = rf(userId, perPage, fromChanneID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(model.ChannelMembersWithTeamData) + } + } + + if rf, ok := ret.Get(1).(func(string, int, string) error); ok { + r1 = rf(userId, perPage, fromChanneID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetMembersForUserWithPagination provides a mock function with given fields: userID, page, perPage func (_m *ChannelStore) GetMembersForUserWithPagination(userID string, page int, perPage int) (model.ChannelMembersWithTeamData, error) { ret := _m.Called(userID, page, perPage) diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 9b00a476cb..baca906301 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -1719,6 +1719,22 @@ func (s *TimerLayerChannelStore) GetMembersForUser(teamID string, userID string) return result, err } +func (s *TimerLayerChannelStore) GetMembersForUserWithCursorPagination(userId string, perPage int, fromChanneID string) (model.ChannelMembersWithTeamData, error) { + start := time.Now() + + result, err := s.ChannelStore.GetMembersForUserWithCursorPagination(userId, perPage, fromChanneID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ChannelStore.GetMembersForUserWithCursorPagination", success, elapsed) + } + return result, err +} + func (s *TimerLayerChannelStore) GetMembersForUserWithPagination(userID string, page int, perPage int) (model.ChannelMembersWithTeamData, error) { start := time.Now() diff --git a/server/channels/web/params.go b/server/channels/web/params.go index ec573eb775..fe5466b8af 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -5,6 +5,7 @@ package web import ( "net/http" + "regexp" "strconv" "strings" @@ -116,6 +117,8 @@ type Params struct { FieldId string } +var getChannelMembersForUserRegex = regexp.MustCompile("/api/v4/users/[A-Za-z0-9]{26}/channel_members") + func ParamsFromRequest(r *http.Request) *Params { params := &Params{} @@ -184,7 +187,9 @@ func ParamsFromRequest(r *http.Request) *Params { params.FieldId = props["field_id"] params.Scope = query.Get("scope") - if val, err := strconv.Atoi(query.Get("page")); err != nil || val < 0 { + if val, err := strconv.Atoi(query.Get("page")); err != nil || (val < 0 && params.UserId == "" && !getChannelMembersForUserRegex.MatchString(r.URL.Path)) { + // We don't want to apply this logic for the getChannelMembersForUser API handler + // because that API allows page=-1 to switch to streaming mode. params.Page = PageDefault } else { params.Page = val diff --git a/server/channels/web/params_test.go b/server/channels/web/params_test.go index 78c0241192..61aedb9f4d 100644 --- a/server/channels/web/params_test.go +++ b/server/channels/web/params_test.go @@ -92,8 +92,7 @@ func TestParamsFromRequest(t *testing.T) { mustURL("?page=hello"), nil, &Params{ - Page: PageDefault, - + Page: PageDefault, PerPage: PerPageDefault, LogsPerPage: LogsPerPageDefault, LimitAfter: LimitDefault, @@ -105,8 +104,7 @@ func TestParamsFromRequest(t *testing.T) { mustURL("?page=-1"), nil, &Params{ - Page: PageDefault, - + Page: PageDefault, PerPage: PerPageDefault, LogsPerPage: LogsPerPageDefault, LimitAfter: LimitDefault, diff --git a/server/public/model/channel_member.go b/server/public/model/channel_member.go index defbb87df2..5d4f8f3336 100644 --- a/server/public/model/channel_member.go +++ b/server/public/model/channel_member.go @@ -108,6 +108,12 @@ type ChannelMemberForExport struct { Username string } +type ChannelMemberCursor struct { + Page int // If page is -1, then FromChannelID is used as a cursor. + PerPage int + FromChannelID string +} + func (o *ChannelMember) IsValid() *AppError { if !IsValidId(o.ChannelId) { return NewAppError("ChannelMember.IsValid", "model.channel_member.is_valid.channel_id.app_error", nil, "", http.StatusBadRequest) diff --git a/server/public/model/client4.go b/server/public/model/client4.go index aedb244f42..d222edf553 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -4,6 +4,7 @@ package model import ( + "bufio" "bytes" "context" "encoding/json" @@ -3696,6 +3697,37 @@ func (c *Client4) GetChannelMembersWithTeamData(ctx context.Context, userID stri defer closeBody(r) var ch ChannelMembersWithTeamData + + // Check if we need to handle NDJSON format (when page is -1) + if page == -1 { + // Process NDJSON format (each JSON object on new line) + contentType := r.Header.Get("Content-Type") + if contentType == "application/x-ndjson" { + scanner := bufio.NewScanner(r.Body) + ch = ChannelMembersWithTeamData{} + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var member ChannelMemberWithTeamData + if err2 := json.Unmarshal([]byte(line), &member); err2 != nil { + return nil, BuildResponse(r), NewAppError("GetChannelMembersWithTeamData", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err2) + } + ch = append(ch, member) + } + + if err2 := scanner.Err(); err2 != nil { + return nil, BuildResponse(r), NewAppError("GetChannelMembersWithTeamData", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err2) + } + + return ch, BuildResponse(r), nil + } + } + + // Standard JSON format err = json.NewDecoder(r.Body).Decode(&ch) if err != nil { return nil, BuildResponse(r), NewAppError("GetChannelMembersWithTeamData", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index e1f3c60808..307f1b9233 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -34,6 +34,7 @@ import { getChannelMemberCountsByGroup, fetchAllMyChannelMembers, fetchAllMyTeamsChannels, + fetchChannelsAndMembers, } from 'mattermost-redux/actions/channels'; import {getCloudSubscription} from 'mattermost-redux/actions/cloud'; import {clearErrors, logError} from 'mattermost-redux/actions/errors'; @@ -882,6 +883,7 @@ async function handleTeamAddedEvent(msg) { await dispatch(TeamActions.getMyTeamMembers()); const state = getState(); await dispatch(TeamActions.getMyTeamUnreads(isCollapsedThreadsEnabled(state))); + await dispatch(fetchChannelsAndMembers(msg.data.team_id)); const license = getLicense(state); if (license.Cloud === 'true') { dispatch(getTeamsUsage()); diff --git a/webapp/channels/src/components/team_controller/actions/index.ts b/webapp/channels/src/components/team_controller/actions/index.ts index 6367f5915f..f7591ca6b3 100644 --- a/webapp/channels/src/components/team_controller/actions/index.ts +++ b/webapp/channels/src/components/team_controller/actions/index.ts @@ -30,14 +30,6 @@ export function initializeTeam(team: Team): ActionFuncAsync { const currentUser = getCurrentUser(state); LocalStorageStore.setPreviousTeamId(currentUser.id, team.id); - try { - await dispatch(fetchChannelsAndMembers(team.id)); - } catch (error) { - forceLogoutIfNecessary(error as ServerError, dispatch, getState); - dispatch(logError(error as ServerError)); - return {error: error as ServerError}; - } - const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); if (enabledUserStatuses) { dispatch(addVisibleUsersInCurrentChannelAndSelfToStatusPoll()); @@ -99,6 +91,14 @@ export function joinTeam(teamname: string, joinedOnFirstLoad: boolean): ActionFu await dispatch(initializeTeam(team)); + try { + await dispatch(fetchChannelsAndMembers(team.id)); + } catch (error) { + forceLogoutIfNecessary(error as ServerError, dispatch, getState); + dispatch(logError(error as ServerError)); + return {error: error as ServerError}; + } + return {data: team}; } throw addUserToTeamResult.error; diff --git a/webapp/channels/src/components/team_controller/team_controller.tsx b/webapp/channels/src/components/team_controller/team_controller.tsx index 0955ee6e29..a05cb84be7 100644 --- a/webapp/channels/src/components/team_controller/team_controller.tsx +++ b/webapp/channels/src/components/team_controller/team_controller.tsx @@ -59,7 +59,6 @@ function TeamController(props: Props) { DesktopApp.reactAppInitialized(); async function fetchAllChannels() { await props.fetchAllMyTeamsChannels(); - setInitialChannelsLoaded(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 9191655365..1c3e18f0a4 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 @@ -2099,14 +2099,13 @@ describe('Actions.Channels', () => { }); nock(Client4.getBaseRoute()).get( - '/users/some-user-id/channel_members?page=0&per_page=200'). - reply(200, [...Array(200).keys()].map((index) => ({channel_id: `channel-${index}`, user_id: 'some-user-id'}))); - nock(Client4.getBaseRoute()).get( - '/users/some-user-id/channel_members?page=1&per_page=200'). - reply(200, [...Array(200).keys()].map((index) => ({channel_id: `channel-${index + 200}`, user_id: 'some-user-id'}))); - nock(Client4.getBaseRoute()).get( - '/users/some-user-id/channel_members?page=2&per_page=200'). - reply(200, [...Array(100).keys()].map((index) => ({channel_id: `channel-${index + 400}`, user_id: 'some-user-id'}))); + '/users/some-user-id/channel_members?page=-1&per_page=60'). + reply(200, [...Array(500).keys()].map((index) => ( + { + channel_id: `channel-${index}`, + user_id: 'some-user-id', + roles: 'channel_user', + }))); await store.dispatch(Actions.fetchAllMyChannelMembers()); expect(Object.keys(store.getState().entities.channels.myMembers).length).toBe(500); 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 f943a1172f..597564ecd1 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -427,8 +427,8 @@ export function getChannelTimezones(channelId: string): ActionFuncAsync { return async (dispatch, getState) => { - let channels; - let channelMembers; + let channels: ServerChannel[] = []; + let channelMembers: ChannelMembership[] = []; try { [channels, channelMembers] = await Promise.all([ Client4.getMyChannels(teamId), @@ -471,32 +471,33 @@ export function fetchAllMyChannelMembers(): ActionFuncAsync { const state = getState(); const {currentUserId} = state.entities.users; - let channelsMembers: ChannelMembership[] = []; - let hasMoreMembers = true; - let page = 0; + let channelMembers: ChannelMembership[] = []; try { - while (hasMoreMembers) { - // Expected to disable since we don't have number of pages, so we can't use Promise.all - // eslint-disable-next-line no-await-in-loop - const data = await Client4.getAllChannelsMembers(currentUserId, page, 200); - channelsMembers = [...channelsMembers, ...data]; - if (data.length < 200) { - hasMoreMembers = false; - } - page++; - } + // The server exposes a streaming API if page is set to -1 + // We don't need to paginate through the responses, and thefore pageSize doesn't matter + channelMembers = await Client4.getAllChannelsMembers(currentUserId, -1); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; } + const roles = new Set(); + for (const member of channelMembers) { + for (const role of member.roles.split(' ')) { + roles.add(role); + } + } + if (roles.size > 0) { + dispatch(loadRolesIfNeeded(roles)); + } + dispatch({ type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, - data: channelsMembers, + data: channelMembers, currentUserId, }); - return {data: channelsMembers}; + return {data: channelMembers}; }; } @@ -512,7 +513,7 @@ export function fetchAllMyTeamsChannels(): ActionFuncAsync { } dispatch({ - type: ChannelTypes.RECEIVED_ALL_CHANNELS, + type: ChannelTypes.RECEIVED_CHANNELS, data: channels, }); return {data: channels}; diff --git a/webapp/platform/client/src/client4.test.ts b/webapp/platform/client/src/client4.test.ts index 46923d78d9..935c154a5e 100644 --- a/webapp/platform/client/src/client4.test.ts +++ b/webapp/platform/client/src/client4.test.ts @@ -4,6 +4,7 @@ import nock from 'nock'; import Client4, {ClientError, HEADER_X_VERSION_ID} from './client4'; +import {buildQueryString} from './helpers'; import type {TelemetryHandler} from './telemetry'; describe('Client4', () => { @@ -40,6 +41,34 @@ describe('Client4', () => { expect(client.serverVersion).toEqual('5.3.0.5.3.0.abc123'); }); + + test('should parse NDJSON responses correctly', async () => { + const client = new Client4(); + client.setUrl('http://mattermost.example.com'); + + const userId = 'dummy-user-id'; + const page = -1; // Special value to trigger NDJSON response + + // Sample NDJSON data with multiple channel memberships on separate lines + const ndjsonData = '{"user_id":"dummy-user-id","channel_id":"channel1","roles":"channel_user"}\n' + + '{"user_id":"dummy-user-id","channel_id":"channel2","roles":"channel_user channel_admin"}\n' + + '{"user_id":"dummy-user-id","channel_id":"channel3","roles":"channel_user"}'; + + // Create a mock endpoint for getAllChannelsMembers that returns NDJSON data + nock(client.getBaseRoute()). + get(`/users/${userId}/channel_members${buildQueryString({page, per_page: 60})}`). + reply(200, ndjsonData, {'Content-Type': 'application/x-ndjson'}); + + // Call the getAllChannelsMembers method which will use our implementation for NDJSON + const result = await client.getAllChannelsMembers(userId, page); + + // Verify the response was parsed as an array of objects + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(3); + expect(result[0]).toEqual({user_id: 'dummy-user-id', channel_id: 'channel1', roles: 'channel_user'}); + expect(result[1]).toEqual({user_id: 'dummy-user-id', channel_id: 'channel2', roles: 'channel_user channel_admin'}); + expect(result[2]).toEqual({user_id: 'dummy-user-id', channel_id: 'channel3', roles: 'channel_user'}); + }); }); }); diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index dc2bfaf1c6..6aec978094 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -4189,8 +4189,13 @@ export default class Client4 { let data; try { - if (headers.get('Content-Type') === 'application/json') { + const contentType = headers.get('Content-Type'); + if (contentType === 'application/json') { data = await response.json(); + } else if (contentType === 'application/x-ndjson') { + const text = await response.text(); + const objects = text.trim().split('\n'); + data = objects.map((obj) => JSON.parse(obj)); } else { data = await response.text(); }