diff --git a/e2e-tests/cypress/tests/integration/channels/channel/archived_channels_1_spec.js b/e2e-tests/cypress/tests/integration/channels/channel/archived_channels_1_spec.js index e2126cd3b8..bc48e130f9 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/archived_channels_1_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/channel/archived_channels_1_spec.js @@ -98,7 +98,7 @@ describe('Leave an archived channel', () => { cy.get('#showMoreChannels').click(); // # More channels modal opens - cy.get('#moreChannelsModal').should('be.visible').within(() => { + cy.get('#browseChannelsModal').should('be.visible').within(() => { // # Click on dropdown cy.findByText(channelType.public).should('be.visible').click(); @@ -146,7 +146,7 @@ describe('Leave an archived channel', () => { cy.get('#showMoreChannels').click(); // # More channels modal opens - cy.get('#moreChannelsModal').should('be.visible').within(() => { + cy.get('#browseChannelsModal').should('be.visible').within(() => { // # Public channel list opens by default cy.findByText(channelType.public).should('be.visible').click(); @@ -199,7 +199,7 @@ describe('Leave an archived channel', () => { cy.get('#showMoreChannels').click(); // # More channels modal opens - cy.get('#moreChannelsModal').should('be.visible').within(() => { + cy.get('#browseChannelsModal').should('be.visible').within(() => { // # Public channels are shown by default cy.findByText(channelType.public).should('be.visible').click(); @@ -253,7 +253,7 @@ describe('Leave an archived channel', () => { cy.get('#showMoreChannels').click(); // # More channels modal opens - cy.get('#moreChannelsModal').should('be.visible').within(() => { + cy.get('#browseChannelsModal').should('be.visible').within(() => { // # Show public channels is visible by default cy.findByText(channelType.public).should('be.visible').click(); @@ -288,7 +288,7 @@ describe('Leave an archived channel', () => { cy.get('#showMoreChannels').click(); // # More channels modal opens and lands on public channels - cy.get('#moreChannelsModal').should('be.visible').within(() => { + cy.get('#browseChannelsModal').should('be.visible').within(() => { cy.findByText(channelType.public).should('be.visible').click(); // # Go to archived channels diff --git a/e2e-tests/cypress/tests/integration/channels/channel/more_channels_spec.js b/e2e-tests/cypress/tests/integration/channels/channel/browse_channels_spec.js similarity index 80% rename from e2e-tests/cypress/tests/integration/channels/channel/more_channels_spec.js rename to e2e-tests/cypress/tests/integration/channels/channel/browse_channels_spec.js index 768508b27e..f50eb4c65d 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/more_channels_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/channel/browse_channels_spec.js @@ -48,9 +48,9 @@ describe('Channels', () => { }); }); - it('MM-19337 Verify UI of More channels modal with archived selection', () => { - verifyMoreChannelsModalWithArchivedSelection(false, testUser, testTeam); - verifyMoreChannelsModalWithArchivedSelection(true, testUser, testTeam); + it('MM-19337 Verify UI of Browse channels modal with archived selection', () => { + verifyBrowseChannelsModalWithArchivedSelection(false, testUser, testTeam); + verifyBrowseChannelsModalWithArchivedSelection(true, testUser, testTeam); }); it('MM-19337 Enable users to view archived channels', () => { @@ -68,7 +68,7 @@ describe('Channels', () => { // # Go to LHS and click 'Browse channels' cy.uiBrowseOrCreateChannel('Browse channels').click(); - cy.get('#moreChannelsModal').should('be.visible').within(() => { + cy.get('#browseChannelsModal').should('be.visible').within(() => { // * Dropdown should be visible, defaulting to "Public Channels" cy.get('#channelsMoreDropdown').should('be.visible').and('contain', channelType.public).wait(TIMEOUTS.HALF_SEC); @@ -86,7 +86,7 @@ describe('Channels', () => { }); // # Verify that the modal is not closed - cy.get('#moreChannelsModal').should('exist'); + cy.get('#browseChannelsModal').should('exist'); cy.url().should('include', `/${testTeam.name}/channels/${testChannel.name}`); // # Login as channel admin and go directly to the channel @@ -111,7 +111,7 @@ describe('Channels', () => { // # Go to LHS and click 'Browse channels' cy.uiBrowseOrCreateChannel('Browse channels').click(); - cy.get('#moreChannelsModal').should('be.visible').within(() => { + cy.get('#browseChannelsModal').should('be.visible').within(() => { // # CLick dropdown to open selection cy.get('#channelsMoreDropdown').should('be.visible').click().within((el) => { // # Click on archived channels item @@ -145,6 +145,38 @@ describe('Channels', () => { cy.get('#sidebar-left').should('not.contain', testChannel.display_name); }); + it('MM-19337 Increase channel member count when user joins a channel', () => { + // # Login as new user and go to "/" + cy.apiLogin(otherUser); + cy.visit(`/${testTeam.name}/channels/town-square`); + + let newChannel; + cy.apiCreateChannel(testTeam.id, 'channel-to-leave', 'Channel to leave').then(({channel}) => { + newChannel = channel; + cy.visit(`/${testTeam.name}/channels/${newChannel.name}`); + + // # Leave the channel + cy.uiLeaveChannel(); + + // * Verify that we've switched to Town Square + cy.url().should('include', '/channels/town-square'); + }); + + // # Go to LHS and click 'Browse channels' + cy.uiBrowseOrCreateChannel('Browse channels').click(); + + cy.get('#browseChannelsModal').should('be.visible').within(() => { + // * Verify that channel has zero members + cy.findByTestId(`channelMemberCount-${newChannel.name}`).should('be.visible').and('contain', 0); + + // # Click the channel row to join the channel + cy.findByTestId(`ChannelRow-${newChannel.name}`).scrollIntoView().should('be.visible').click(); + + // * Verify that channel has one member + cy.findByTestId(`channelMemberCount-${newChannel.name}`).should('be.visible').and('contain', 1); + }); + }); + it('MM-T1702 Search works when changing public/archived options in the dropdown', () => { cy.apiAdminLogin(); cy.apiUpdateConfig({ @@ -210,7 +242,7 @@ describe('Channels', () => { cy.findByText(newChannel.display_name).should('be.visible'); }); - cy.get('#moreChannelsModal').should('be.visible').within(() => { + cy.get('#browseChannelsModal').should('be.visible').within(() => { // * Users should be able to switch to "Archived Channels" list cy.get('#channelsMoreDropdown').should('be.visible').and('contain', channelType.public).click().within((el) => { // # Click on archived channels item @@ -229,7 +261,7 @@ describe('Channels', () => { }); }); -function verifyMoreChannelsModalWithArchivedSelection(isEnabled, testUser, testTeam) { +function verifyBrowseChannelsModalWithArchivedSelection(isEnabled, testUser, testTeam) { // # Login as sysadmin and Update config to enable/disable viewing of archived channels cy.apiAdminLogin(); cy.apiUpdateConfig({ @@ -238,22 +270,22 @@ function verifyMoreChannelsModalWithArchivedSelection(isEnabled, testUser, testT }, }); - // * Verify more channels modal + // * Verify browse channels modal cy.visit(`/${testTeam.name}/channels/town-square`); - verifyMoreChannelsModal(isEnabled); + verifyBrowseChannelsModal(isEnabled); - // # Login as regular user and verify more channels modal + // # Login as regular user and verify browse channels modal cy.apiLogin(testUser); cy.visit(`/${testTeam.name}/channels/town-square`); - verifyMoreChannelsModal(isEnabled); + verifyBrowseChannelsModal(isEnabled); } -function verifyMoreChannelsModal(isEnabled) { +function verifyBrowseChannelsModal(isEnabled) { // # Go to LHS and click 'Browse channels' cy.uiBrowseOrCreateChannel('Browse channels').click(); - // * Verify that the more channels modal is open and with or without option to view archived channels - cy.get('#moreChannelsModal').should('be.visible').within(() => { + // * Verify that the browse channels modal is open and with or without option to view archived channels + cy.get('#browseChannelsModal').should('be.visible').within(() => { if (isEnabled) { cy.get('#channelsMoreDropdown').should('be.visible').and('have.text', channelType.public); } else { diff --git a/e2e-tests/cypress/tests/integration/channels/channel/more_public_channels_spec.js b/e2e-tests/cypress/tests/integration/channels/channel/more_public_channels_spec.js index 381d621e6a..ec150d22bb 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel/more_public_channels_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/channel/more_public_channels_spec.js @@ -53,8 +53,12 @@ describe('more public channels', () => { // * Assert that the moreChannelsModel is visible cy.findByRole('dialog', {name: 'Browse Channels'}).should('be.visible').within(() => { - // # Click hide joined checkbox - cy.findByText('Hide Joined').should('be.visible').click(); + // # Click hide joined checkbox if not already checked + cy.findByText('Hide Joined').should('be.visible').then(($checkbox) => { + if (!$checkbox.prop('checked')) { + cy.wrap($checkbox).click(); + } + }); // * Assert that the moreChannelsList is visible and the number of channels is 31 cy.get('#moreChannelsList').should('be.visible').children().should('have.length', 31); diff --git a/e2e-tests/cypress/tests/integration/channels/channel_sidebar/new_channel_dropdown_spec.ts b/e2e-tests/cypress/tests/integration/channels/channel_sidebar/new_channel_dropdown_spec.ts index 777966ed97..96655a6902 100644 --- a/e2e-tests/cypress/tests/integration/channels/channel_sidebar/new_channel_dropdown_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/channel_sidebar/new_channel_dropdown_spec.ts @@ -68,13 +68,13 @@ describe('Channel sidebar', () => { cy.get('.AddChannelDropdown .MenuItem:contains(Browse channels) button').should('be.visible').click(); // * Verify that the more channels modal is visible - cy.get('#moreChannelsModal').should('be.visible'); + cy.get('#browseChannelsModal').should('be.visible'); // Click the Off-Topic channel cy.findByText('Off-Topic').should('be.visible').click(); // Verify that new channel is in the sidebar and is active - cy.get('#moreChannelsModal').should('exist'); + cy.get('#browseChannelsModal').should('exist'); cy.url().should('include', `/${teamName}/channels/off-topic`); cy.get('#channelHeaderTitle').should('contain', 'Off-Topic'); cy.get('.SidebarChannel.active:contains(Off-Topic)').should('be.visible'); diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index 4e73799072..ba433e73a0 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -25,6 +25,7 @@ func (api *API) InitChannel() { api.BaseRoutes.Channels.Handle("/group", api.APISessionRequired(createGroupChannel)).Methods("POST") api.BaseRoutes.Channels.Handle("/members/{user_id:[A-Za-z0-9]+}/view", api.APISessionRequired(viewChannel)).Methods("POST") api.BaseRoutes.Channels.Handle("/{channel_id:[A-Za-z0-9]+}/scheme", api.APISessionRequired(updateChannelScheme)).Methods("PUT") + api.BaseRoutes.Channels.Handle("/stats/member_count", api.APISessionRequired(getChannelsMemberCount)).Methods("POST") api.BaseRoutes.ChannelsForTeam.Handle("", api.APISessionRequired(getPublicChannelsForTeam)).Methods("GET") api.BaseRoutes.ChannelsForTeam.Handle("/deleted", api.APISessionRequired(getDeletedChannelsForTeam)).Methods("GET") @@ -681,6 +682,29 @@ func getChannelStats(c *Context, w http.ResponseWriter, r *http.Request) { } } +func getChannelsMemberCount(c *Context, w http.ResponseWriter, r *http.Request) { + if c.Err != nil { + return + } + + channelIDs := model.ArrayFromJSON(r.Body) + if !c.App.SessionHasPermissionToChannels(c.AppContext, *c.AppContext.Session(), channelIDs, model.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } + + channelsMemberCount, err := c.App.GetChannelsMemberCount(c.AppContext, channelIDs) + + if err != nil { + c.Err = err + return + } + + if err := json.NewEncoder(w).Encode(channelsMemberCount); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func getPinnedPosts(c *Context, w http.ResponseWriter, r *http.Request) { c.RequireChannelId() if c.Err != nil { diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index 3f9a74ad4b..412e3a0169 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -4469,6 +4469,74 @@ func TestGetChannelMemberCountsByGroup(t *testing.T) { }) } +func TestGetChannelsMemberCount(t *testing.T) { + // Setup + th := Setup(t).InitBasic() + defer th.TearDown() + + client := th.Client + + channel1 := th.CreatePublicChannel() + channel2 := th.CreatePublicChannel() + + user1 := th.CreateUser() + user2 := th.CreateUser() + user3 := th.CreateUser() + + th.LinkUserToTeam(user1, th.BasicTeam) + th.LinkUserToTeam(user2, th.BasicTeam) + th.LinkUserToTeam(user3, th.BasicTeam) + + th.AddUserToChannel(user1, channel1) + th.AddUserToChannel(user2, channel1) + th.AddUserToChannel(user3, channel1) + th.AddUserToChannel(user2, channel2) + + t.Run("Should return correct member count", func(t *testing.T) { + // Create a request with channel IDs + channelIDs := []string{channel1.Id, channel2.Id} + channelsMemberCount, _, err := client.GetChannelsMemberCount(context.Background(), channelIDs) + require.NoError(t, err) + + // Verify the member counts + require.Contains(t, channelsMemberCount, channel1.Id) + require.Contains(t, channelsMemberCount, channel2.Id) + require.Equal(t, int64(4), channelsMemberCount[channel1.Id]) + require.Equal(t, int64(2), channelsMemberCount[channel2.Id]) + }) + + t.Run("Should return empty object when empty array is passed", func(t *testing.T) { + channelsMemberCount, _, err := client.GetChannelsMemberCount(context.Background(), []string{}) + require.NoError(t, err) + require.Equal(t, 0, len(channelsMemberCount)) + }) + + t.Run("Should fail due to permissions", func(t *testing.T) { + _, resp, err := client.GetChannelsMemberCount(context.Background(), []string{"junk"}) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + CheckErrorID(t, err, "api.context.permissions.app_error") + }) + + t.Run("Should fail due to expired session when logged out", func(t *testing.T) { + client.Logout(context.Background()) + channelIDs := []string{channel1.Id, channel2.Id} + _, resp, err := client.GetChannelsMemberCount(context.Background(), channelIDs) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) + CheckErrorID(t, err, "api.context.session_expired.app_error") + }) + + t.Run("Should fail due to expired session when logged out", func(t *testing.T) { + th.LoginBasic2() + channelIDs := []string{channel1.Id, channel2.Id} + _, resp, err := client.GetChannelsMemberCount(context.Background(), channelIDs) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + CheckErrorID(t, err, "api.context.permissions.app_error") + }) +} + func TestMoveChannel(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 1ab0e356c3..9bebac79a3 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -625,6 +625,7 @@ type AppIface interface { GetChannelsForTeamForUser(c request.CTX, teamID string, userID string, opts *model.ChannelSearchOpts) (model.ChannelList, *model.AppError) GetChannelsForTeamForUserWithCursor(c request.CTX, teamID string, userID string, opts *model.ChannelSearchOpts, afterChannelID string) (model.ChannelList, *model.AppError) GetChannelsForUser(c request.CTX, userID string, includeDeleted bool, lastDeleteAt, pageSize int, fromChannelID string) (model.ChannelList, *model.AppError) + GetChannelsMemberCount(c request.CTX, channelIDs []string) (map[string]int64, *model.AppError) GetChannelsUserNotIn(c request.CTX, teamID string, userID string, offset int, limit int) (model.ChannelList, *model.AppError) GetCloudSession(token string) (*model.Session, *model.AppError) GetClusterId() string diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 891a1af78b..3e6362ab26 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -1834,6 +1834,20 @@ func (a *App) GetChannels(c request.CTX, channelIDs []string) ([]*model.Channel, return channels, nil } +func (a *App) GetChannelsMemberCount(c request.CTX, channelIDs []string) (map[string]int64, *model.AppError) { + channelsCount, err := a.Srv().Store().Channel().GetChannelsMemberCount(channelIDs) + if err != nil { + var nfErr *store.ErrNotFound + switch { + case errors.As(err, &nfErr): + return nil, model.NewAppError("GetChannelsMemberCount", "app.channel.get_channels_member_count.existing.app_error", nil, "", http.StatusNotFound).Wrap(err) + default: + return nil, model.NewAppError("GetChannelsMemberCount", "app.channel.get_channels_member_count.find.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + return channelsCount, nil +} + func (a *App) GetChannelByName(c request.CTX, channelName, teamID string, includeDeleted bool) (*model.Channel, *model.AppError) { var channel *model.Channel var err error diff --git a/server/channels/app/channel_test.go b/server/channels/app/channel_test.go index 711476f9ea..9b9d5ff44e 100644 --- a/server/channels/app/channel_test.go +++ b/server/channels/app/channel_test.go @@ -2195,6 +2195,24 @@ func TestGetMemberCountsByGroup(t *testing.T) { require.ElementsMatch(t, cmc, resp) } +func TestGetChannelsMemberCount(t *testing.T) { + th := SetupWithStoreMock(t) + defer th.TearDown() + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockChannelStore := mocks.ChannelStore{} + channelsMemberCount := map[string]int64{ + "channel1": int64(10), + "channel2": int64(20), + } + mockChannelStore.On("GetChannelsMemberCount", []string{"channel1", "channel2"}).Return(channelsMemberCount, nil) + mockStore.On("Channel").Return(&mockChannelStore) + mockStore.On("GetDBSchemaVersion").Return(1, nil) + resp, err := th.App.GetChannelsMemberCount(th.Context, []string{"channel1", "channel2"}) + require.Nil(t, err) + require.Equal(t, channelsMemberCount, resp) +} + func TestViewChannelCollapsedThreadsTurnedOff(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index a3bdadab5d..7e5991ddc7 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -5612,6 +5612,28 @@ func (a *OpenTracingAppLayer) GetChannelsForUser(c request.CTX, userID string, i return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetChannelsMemberCount(c request.CTX, channelIDs []string) (map[string]int64, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetChannelsMemberCount") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetChannelsMemberCount(c, channelIDs) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetChannelsUserNotIn(c request.CTX, teamID string, userID string, offset int, limit int) (model.ChannelList, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetChannelsUserNotIn") diff --git a/server/channels/store/localcachelayer/channel_layer.go b/server/channels/store/localcachelayer/channel_layer.go index 41501de9b8..134912cc7c 100644 --- a/server/channels/store/localcachelayer/channel_layer.go +++ b/server/channels/store/localcachelayer/channel_layer.go @@ -225,6 +225,35 @@ func (s LocalCacheChannelStore) SaveMultipleMembers(members []*model.ChannelMemb return members, nil } +func (s LocalCacheChannelStore) GetChannelsMemberCount(channelIDs []string) (_ map[string]int64, err error) { + counts := make(map[string]int64) + remainingChannels := make([]string, 0) + + for _, channelID := range channelIDs { + var cacheItem int64 + err := s.rootStore.doStandardReadCache(s.rootStore.channelMemberCountsCache, channelID, &cacheItem) + if err == nil { + counts[channelID] = cacheItem + } else { + remainingChannels = append(remainingChannels, channelID) + } + } + + if len(remainingChannels) > 0 { + remainingChannels, err := s.ChannelStore.GetChannelsMemberCount(remainingChannels) + if err != nil { + return nil, err + } + + for id, count := range remainingChannels { + s.rootStore.doStandardAddToCache(s.rootStore.channelMemberCountsCache, id, count) + counts[id] = count + } + } + + return counts, nil +} + func (s LocalCacheChannelStore) UpdateMember(member *model.ChannelMember) (*model.ChannelMember, error) { member, err := s.ChannelStore.UpdateMember(member) if err != nil { diff --git a/server/channels/store/localcachelayer/channel_layer_test.go b/server/channels/store/localcachelayer/channel_layer_test.go index b3efa39e6f..76aefe1879 100644 --- a/server/channels/store/localcachelayer/channel_layer_test.go +++ b/server/channels/store/localcachelayer/channel_layer_test.go @@ -104,6 +104,43 @@ func TestChannelStoreChannelMemberCountsCache(t *testing.T) { }) } +func TestChannelStoreChannelsMemberCountCache(t *testing.T) { + channelsCountResult := map[string]int64{ + "channel1": 10, + "channel2": 20, + } + + t.Run("first call not cached, second cached and returning same data", func(t *testing.T) { + mockStore := getMockStore() + mockCacheProvider := getMockCacheProvider() + cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider) + require.NoError(t, err) + + channelsCount, err := cachedStore.Channel().GetChannelsMemberCount([]string{"channel1", "channel2"}) + require.NoError(t, err) + assert.Equal(t, channelsCount, channelsCountResult) + mockStore.Channel().(*mocks.ChannelStore).AssertNumberOfCalls(t, "GetChannelsMemberCount", 1) + channelsCount, err = cachedStore.Channel().GetChannelsMemberCount([]string{"channel1", "channel2"}) + require.NoError(t, err) + assert.Equal(t, channelsCount, channelsCountResult) + mockStore.Channel().(*mocks.ChannelStore).AssertNumberOfCalls(t, "GetChannelsMemberCount", 1) + }) + + t.Run("first call not cached, invalidate cache, second call not cached", func(t *testing.T) { + mockStore := getMockStore() + mockCacheProvider := getMockCacheProvider() + cachedStore, err := NewLocalCacheLayer(mockStore, nil, nil, mockCacheProvider) + require.NoError(t, err) + + cachedStore.Channel().GetChannelsMemberCount([]string{"channel1", "channel2"}) + mockStore.Channel().(*mocks.ChannelStore).AssertNumberOfCalls(t, "GetChannelsMemberCount", 1) + cachedStore.Channel().InvalidateMemberCount("channel1") + cachedStore.Channel().InvalidateMemberCount("channel2") + cachedStore.Channel().GetChannelsMemberCount([]string{"channel1", "channel2"}) + mockStore.Channel().(*mocks.ChannelStore).AssertNumberOfCalls(t, "GetChannelsMemberCount", 2) + }) +} + func TestChannelStoreChannelPinnedPostsCountsCache(t *testing.T) { countResult := int64(10) diff --git a/server/channels/store/localcachelayer/main_test.go b/server/channels/store/localcachelayer/main_test.go index 4a21a320c4..9fc4fb3385 100644 --- a/server/channels/store/localcachelayer/main_test.go +++ b/server/channels/store/localcachelayer/main_test.go @@ -100,6 +100,12 @@ func getMockStore() *mocks.Store { mockChannelStore.On("Get", channelId, false).Return(&fakeChannelId, nil) mockStore.On("Channel").Return(&mockChannelStore) + mockChannelsMemberCount := map[string]int64{ + "channel1": 10, + "channel2": 20, + } + mockChannelStore.On("GetChannelsMemberCount", []string{"channel1", "channel2"}).Return(mockChannelsMemberCount, nil) + mockPinnedPostsCount := int64(10) mockChannelStore.On("GetPinnedPostCount", "id", true).Return(mockPinnedPostsCount, nil) mockChannelStore.On("GetPinnedPostCount", "id", false).Return(mockPinnedPostsCount, nil) diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 7aa193a317..919e5bf6d4 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -1269,6 +1269,24 @@ func (s *OpenTracingLayerChannelStore) GetChannelsByUser(userID string, includeD return result, err } +func (s *OpenTracingLayerChannelStore) GetChannelsMemberCount(channelIDs []string) (map[string]int64, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.GetChannelsMemberCount") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.ChannelStore.GetChannelsMemberCount(channelIDs) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerChannelStore) GetChannelsWithCursor(teamId string, userId string, opts *model.ChannelSearchOpts, afterChannelID string) (model.ChannelList, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.GetChannelsWithCursor") diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 7f1f602e91..0fc64a7833 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -1406,6 +1406,27 @@ func (s *RetryLayerChannelStore) GetChannelsByUser(userID string, includeDeleted } +func (s *RetryLayerChannelStore) GetChannelsMemberCount(channelIDs []string) (map[string]int64, error) { + + tries := 0 + for { + result, err := s.ChannelStore.GetChannelsMemberCount(channelIDs) + 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) GetChannelsWithCursor(teamId string, userId string, opts *model.ChannelSearchOpts, afterChannelID string) (model.ChannelList, error) { tries := 0 diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index 8604b0ca23..e97e50efc5 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -2207,6 +2207,47 @@ func (s SqlChannelStore) GetAllChannelMembersForUser(userId string, allowFromCac return ids, nil } +func (s SqlChannelStore) GetChannelsMemberCount(channelIDs []string) (_ map[string]int64, err error) { + query := s.getQueryBuilder(). + Select("ChannelMembers.ChannelId,COUNT(*) AS Count"). + From("ChannelMembers"). + InnerJoin("Users ON ChannelMembers.UserId = Users.Id"). + Where(sq.And{ + sq.Eq{"ChannelMembers.ChannelId": channelIDs}, + sq.Eq{"Users.DeleteAt": 0}, + }). + GroupBy("ChannelMembers.ChannelId") + + queryString, args, err := query.ToSql() + if err != nil { + return nil, errors.Wrap(err, "channels_member_count_tosql") + } + + rows, err := s.GetReplicaX().DB.Query(queryString, args...) + + if err != nil { + return nil, errors.Wrap(err, "failed to fetch member counts") + } + defer rows.Close() + + memberCounts := make(map[string]int64) + for rows.Next() { + var channelID string + var count int64 + errScan := rows.Scan(&channelID, &count) + if errScan != nil { + return nil, errors.Wrap(err, "failed to scan row") + } + memberCounts[channelID] = count + } + + if err = rows.Err(); err != nil { + return nil, errors.Wrap(err, "error while iterating rows") + } + + return memberCounts, nil +} + func (s SqlChannelStore) InvalidateCacheForChannelMembersNotifyProps(channelId string) { allChannelMembersNotifyPropsForChannelCache.Remove(channelId) if s.metrics != nil { diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 3e2798e5e4..d4c0d4d006 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -222,6 +222,7 @@ type ChannelStore interface { GetMember(ctx context.Context, channelID string, userID string) (*model.ChannelMember, error) GetChannelMembersTimezones(channelID string) ([]model.StringMap, error) GetAllChannelMembersForUser(userID string, allowFromCache bool, includeDeleted bool) (map[string]string, error) + GetChannelsMemberCount(channelIDs []string) (map[string]int64, error) InvalidateAllChannelMembersForUser(userID string) IsUserInChannelUseCache(userID string, channelID string) bool GetAllChannelMembersNotifyPropsForChannel(channelID string, allowFromCache bool) (map[string]model.StringMap, error) diff --git a/server/channels/store/storetest/mocks/ChannelStore.go b/server/channels/store/storetest/mocks/ChannelStore.go index 48ee4b9297..64520f4df9 100644 --- a/server/channels/store/storetest/mocks/ChannelStore.go +++ b/server/channels/store/storetest/mocks/ChannelStore.go @@ -910,6 +910,32 @@ func (_m *ChannelStore) GetChannelsByUser(userID string, includeDeleted bool, la return r0, r1 } +// GetChannelsMemberCount provides a mock function with given fields: channelIDs +func (_m *ChannelStore) GetChannelsMemberCount(channelIDs []string) (map[string]int64, error) { + ret := _m.Called(channelIDs) + + var r0 map[string]int64 + var r1 error + if rf, ok := ret.Get(0).(func([]string) (map[string]int64, error)); ok { + return rf(channelIDs) + } + if rf, ok := ret.Get(0).(func([]string) map[string]int64); ok { + r0 = rf(channelIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]int64) + } + } + + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(channelIDs) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetChannelsWithCursor provides a mock function with given fields: teamId, userId, opts, afterChannelID func (_m *ChannelStore) GetChannelsWithCursor(teamId string, userId string, opts *model.ChannelSearchOpts, afterChannelID string) (model.ChannelList, error) { ret := _m.Called(teamId, userId, opts, afterChannelID) diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 8fe2245795..126a256445 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -1184,6 +1184,22 @@ func (s *TimerLayerChannelStore) GetChannelsByUser(userID string, includeDeleted return result, err } +func (s *TimerLayerChannelStore) GetChannelsMemberCount(channelIDs []string) (map[string]int64, error) { + start := time.Now() + + result, err := s.ChannelStore.GetChannelsMemberCount(channelIDs) + + 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.GetChannelsMemberCount", success, elapsed) + } + return result, err +} + func (s *TimerLayerChannelStore) GetChannelsWithCursor(teamId string, userId string, opts *model.ChannelSearchOpts, afterChannelID string) (model.ChannelList, error) { start := time.Now() diff --git a/server/i18n/en.json b/server/i18n/en.json index ebf971ecb2..c43a5be52a 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -4739,6 +4739,14 @@ "id": "app.channel.get_channels_by_ids.not_found.app_error", "translation": "No channel found." }, + { + "id": "app.channel.get_channels_member_count.existing.app_error", + "translation": "Unable to find member count for given channels." + }, + { + "id": "app.channel.get_channels_member_count.find.app_error", + "translation": "Unable to find member count." + }, { "id": "app.channel.get_deleted.existing.app_error", "translation": "Unable to find the existing deleted channel." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 6c03cea601..acf950fa69 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -3047,6 +3047,21 @@ func (c *Client4) GetChannelStats(ctx context.Context, channelId string, etag st return &stats, BuildResponse(r), nil } +// GetChannelsMemberCount get channel member count for a given array of channel ids +func (c *Client4) GetChannelsMemberCount(ctx context.Context, channelIDs []string) (map[string]int64, *Response, error) { + route := c.channelsRoute() + "/stats/member_count" + r, err := c.DoAPIPost(ctx, route, ArrayToJSON(channelIDs)) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var counts map[string]int64 + if err := json.NewDecoder(r.Body).Decode(&counts); err != nil { + return nil, nil, NewAppError("GetChannelsMemberCount", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return counts, BuildResponse(r), nil +} + // GetChannelMembersTimezones gets a list of timezones for a channel. func (c *Client4) GetChannelMembersTimezones(ctx context.Context, channelId string) ([]string, *Response, error) { r, err := c.DoAPIGet(ctx, c.channelRoute(channelId)+"/timezones", "") diff --git a/server/public/model/cluster_message.go b/server/public/model/cluster_message.go index 35bae0371e..6ff912f9ac 100644 --- a/server/public/model/cluster_message.go +++ b/server/public/model/cluster_message.go @@ -29,6 +29,7 @@ const ( ClusterEventInvalidateCacheForChannelFileCount ClusterEvent = "inv_channel_file_count" ClusterEventInvalidateCacheForChannelPinnedpostsCounts ClusterEvent = "inv_channel_pinnedposts_counts" ClusterEventInvalidateCacheForChannelMemberCounts ClusterEvent = "inv_channel_member_counts" + ClusterEventInvalidateCacheForChannelsMemberCount ClusterEvent = "inv_channels_member_count" ClusterEventInvalidateCacheForLastPosts ClusterEvent = "inv_last_posts" ClusterEventInvalidateCacheForLastPostTime ClusterEvent = "inv_last_post_time" ClusterEventInvalidateCacheForPostsUsage ClusterEvent = "inv_posts_usage" diff --git a/webapp/channels/src/components/more_channels/__snapshots__/more_channels.test.tsx.snap b/webapp/channels/src/components/browse_channels/__snapshots__/browse_channels.test.tsx.snap similarity index 95% rename from webapp/channels/src/components/more_channels/__snapshots__/more_channels.test.tsx.snap rename to webapp/channels/src/components/browse_channels/__snapshots__/browse_channels.test.tsx.snap index eb0a190f2c..4eca23e4db 100644 --- a/webapp/channels/src/components/more_channels/__snapshots__/more_channels.test.tsx.snap +++ b/webapp/channels/src/components/browse_channels/__snapshots__/browse_channels.test.tsx.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`components/MoreChannels should match snapshot and state 1`] = ` +exports[`components/BrowseChannels should match snapshot and state 1`] = ` } - id="moreChannelsModal" + id="browseChannelsModal" keyboardEscape={true} modalHeaderText={ { +describe('components/BrowseChannels', () => { const searchResults = { data: [{ id: 'channel-id-1', @@ -29,6 +30,15 @@ describe('components/MoreChannels', () => { }], }; + const archivedChannel = TestHelper.getChannelMock({ + id: 'channel_id_2', + team_id: 'channel_team_2', + display_name: 'channel-2', + name: 'channel-2', + header: 'channel-2-header', + purpose: 'channel-2-purpose', + }); + const channelActions = { joinChannelAction: (userId: string, teamId: string, channelId: string): Promise => { return new Promise((resolve) => { @@ -56,18 +66,25 @@ describe('components/MoreChannels', () => { return resolve(searchResults); }); }, + getChannels: (): Promise> => { + return new Promise((resolve) => { + return resolve({ + data: [TestHelper.getChannelMock({})], + }); + }); + }, + getArchivedChannels: (): Promise> => { + return new Promise((resolve) => { + return resolve({ + data: [archivedChannel], + }); + }); + }, }; const baseProps: Props = { channels: [TestHelper.getChannelMock({})], - archivedChannels: [TestHelper.getChannelMock({ - id: 'channel_id_2', - team_id: 'channel_team_2', - display_name: 'channel-2', - name: 'channel-2', - header: 'channel-2-header', - purpose: 'channel-2-purpose', - })], + archivedChannels: [archivedChannel], currentUserId: 'user-1', teamId: 'team_id', teamName: 'team_name', @@ -76,20 +93,21 @@ describe('components/MoreChannels', () => { shouldHideJoinedChannels: false, myChannelMemberships: {}, actions: { - getChannels: jest.fn(), - getArchivedChannels: jest.fn(), + getChannels: jest.fn(channelActions.getChannels), + getArchivedChannels: jest.fn(channelActions.getArchivedChannels), joinChannel: jest.fn(channelActions.joinChannelAction), searchMoreChannels: jest.fn(channelActions.searchMoreChannels), openModal: jest.fn(), closeModal: jest.fn(), closeRightHandSide: jest.fn(), setGlobalItem: jest.fn(), + getChannelsMemberCount: jest.fn(), }, }; test('should match snapshot and state', () => { - const wrapper = shallow( - , + const wrapper = shallow( + , ); expect(wrapper).toMatchSnapshot(); @@ -105,8 +123,8 @@ describe('components/MoreChannels', () => { }); test('should call closeModal on handleExit', () => { - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().handleExit(); @@ -114,8 +132,8 @@ describe('components/MoreChannels', () => { }); test('should match state on onChange', () => { - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.setState({searchedChannels: [TestHelper.getChannelMock({id: 'other_channel_id'})]}); @@ -129,8 +147,8 @@ describe('components/MoreChannels', () => { }); test('should call props.getChannels on nextPage', () => { - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().nextPage(1); @@ -141,7 +159,7 @@ describe('components/MoreChannels', () => { test('should have loading prop true when searching state is true', () => { const wrapper = shallow( - , + , ); wrapper.setState({search: true, searching: true}); @@ -164,8 +182,8 @@ describe('components/MoreChannels', () => { }, }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); const callback = jest.fn(); @@ -192,8 +210,8 @@ describe('components/MoreChannels', () => { }, }; - const wrapper = shallow( - , + const wrapper = shallow( + , ); const callback = jest.fn(); @@ -208,8 +226,8 @@ describe('components/MoreChannels', () => { }); test('should not perform a search if term is empty', () => { - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().onChange = jest.fn(); @@ -223,8 +241,8 @@ describe('components/MoreChannels', () => { }); test('should handle a failed search', (done) => { - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().onChange = jest.fn(); @@ -251,8 +269,8 @@ describe('components/MoreChannels', () => { }); test('should perform search and set the correct state', (done) => { - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().onChange = jest.fn(); @@ -277,8 +295,8 @@ describe('components/MoreChannels', () => { }); test('should perform search on archived channels and set the correct state', (done) => { - const wrapper = shallow( - , + const wrapper = shallow( + , ); wrapper.instance().onChange = jest.fn(); diff --git a/webapp/channels/src/components/more_channels/more_channels.tsx b/webapp/channels/src/components/browse_channels/browse_channels.tsx similarity index 88% rename from webapp/channels/src/components/more_channels/more_channels.tsx rename to webapp/channels/src/components/browse_channels/browse_channels.tsx index 6c797cc342..f6f45444be 100644 --- a/webapp/channels/src/components/more_channels/more_channels.tsx +++ b/webapp/channels/src/components/browse_channels/browse_channels.tsx @@ -24,15 +24,15 @@ import classNames from 'classnames'; import {localizeMessage} from 'utils/utils'; import LoadingScreen from 'components/loading_screen'; -import './more_channels.scss'; +import './browse_channels.scss'; const CHANNELS_CHUNK_SIZE = 50; const CHANNELS_PER_PAGE = 50; const SEARCH_TIMEOUT_MILLISECONDS = 100; type Actions = { - getChannels: (teamId: string, page: number, perPage: number) => void; - getArchivedChannels: (teamId: string, page: number, channelsPerPage: number) => void; + getChannels: (teamId: string, page: number, perPage: number) => Promise>; + getArchivedChannels: (teamId: string, page: number, channelsPerPage: number) => Promise>; joinChannel: (currentUserId: string, teamId: string, channelId: string) => Promise; searchMoreChannels: (term: string, shouldShowArchivedChannels: boolean, shouldHideJoinedChannels: boolean) => Promise; openModal:

(modalData: ModalData

) => void; @@ -43,6 +43,7 @@ type Actions = { */ setGlobalItem: (name: string, value: string) => void; closeRightHandSide: () => void; + getChannelsMemberCount: (channelIds: string[]) => Promise; } export type Props = { @@ -58,6 +59,7 @@ export type Props = { shouldHideJoinedChannels: boolean; rhsState?: RhsState; rhsOpen?: boolean; + channelsMemberCount?: Record; actions: Actions; } @@ -71,7 +73,7 @@ type State = { searchTerm: string; } -export default class MoreChannels extends React.PureComponent { +export default class BrowseChannels extends React.PureComponent { public searchTimeoutId: number; activeChannels: Channel[] = []; @@ -92,10 +94,23 @@ export default class MoreChannels extends React.PureComponent { } componentDidMount() { - this.props.actions.getChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2); + const promises = [ + this.props.actions.getChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2), + ]; + if (this.props.canShowArchivedChannels) { - this.props.actions.getArchivedChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2); + promises.push(this.props.actions.getArchivedChannels(this.props.teamId, 0, CHANNELS_CHUNK_SIZE * 2)); } + + Promise.all(promises).then((results) => { + const channelIDsForMemberCount = results.flatMap((result) => { + return result.data ? result.data.map((channel) => channel.id) : []; + }, + ); + if (channelIDsForMemberCount.length > 0) { + this.props.actions.getChannelsMemberCount(channelIDsForMemberCount); + } + }); this.loadComplete(); } @@ -134,7 +149,11 @@ export default class MoreChannels extends React.PureComponent { }; nextPage = (page: number) => { - this.props.actions.getChannels(this.props.teamId, page + 1, CHANNELS_PER_PAGE); + this.props.actions.getChannels(this.props.teamId, page + 1, CHANNELS_PER_PAGE).then((result) => { + if (result.data && result.data.length > 0) { + this.props.actions.getChannelsMemberCount(result.data.map((channel) => channel.id)); + } + }); }; handleJoin = async (channel: Channel, done: () => void) => { @@ -148,6 +167,7 @@ export default class MoreChannels extends React.PureComponent { if (result?.error) { this.setState({serverError: result.error.message}); } else { + this.props.actions.getChannelsMemberCount([channel.id]); getHistory().push(getRelativeChannelURL(teamName, channel.name)); this.closeEditRHS(); } @@ -211,9 +231,6 @@ export default class MoreChannels extends React.PureComponent { this.props.actions.setGlobalItem(StoragePrefixes.HIDE_JOINED_CHANNELS, shouldHideJoinedChannels.toString()); }; - otherChannelsWithoutJoined = this.props.channels.filter((channel) => !this.isMemberOfChannel(channel.id)); - archivedChannelsWithoutJoined = this.props.archivedChannels.filter((channel) => !this.isMemberOfChannel(channel.id)); - render() { const { channels, @@ -304,6 +321,7 @@ export default class MoreChannels extends React.PureComponent { closeModal={this.props.actions.closeModal} hideJoinedChannelsPreference={this.handleShowJoinedChannelsPreference} rememberHideJoinedChannelsChecked={shouldHideJoinedChannels} + channelsMemberCount={this.props.channelsMemberCount} /> {serverError} @@ -319,8 +337,8 @@ export default class MoreChannels extends React.PureComponent { return ( void; - getArchivedChannels: (teamId: string, page: number, channelsPerPage: number) => void; + getChannels: (teamId: string, page: number, perPage: number) => Promise>; + getArchivedChannels: (teamId: string, page: number, channelsPerPage: number) => Promise>; joinChannel: (currentUserId: string, teamId: string, channelId: string) => Promise; searchMoreChannels: (term: string, shouldShowArchivedChannels: boolean) => Promise; openModal:

(modalData: ModalData

) => void; closeModal: (modalId: string) => void; setGlobalItem: (name: string, value: string) => void; closeRightHandSide: () => void; + getChannelsMemberCount: (channelIds: string[]) => Promise; } function mapDispatchToProps(dispatch: Dispatch) { @@ -81,8 +83,9 @@ function mapDispatchToProps(dispatch: Dispatch) { closeModal, setGlobalItem, closeRightHandSide, + getChannelsMemberCount, }, dispatch), }; } -export default connect(mapStateToProps, mapDispatchToProps)(MoreChannels); +export default connect(mapStateToProps, mapDispatchToProps)(BrowseChannels); diff --git a/webapp/channels/src/components/searchable_channel_list.tsx b/webapp/channels/src/components/searchable_channel_list.tsx index aa395d29ce..cda66baab4 100644 --- a/webapp/channels/src/components/searchable_channel_list.tsx +++ b/webapp/channels/src/components/searchable_channel_list.tsx @@ -4,7 +4,7 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; -import {ArchiveOutlineIcon, CheckIcon, ChevronDownIcon, GlobeIcon, LockOutlineIcon, MagnifyIcon} from '@mattermost/compass-icons/components'; +import {ArchiveOutlineIcon, CheckIcon, ChevronDownIcon, GlobeIcon, LockOutlineIcon, MagnifyIcon, AccountOutlineIcon} from '@mattermost/compass-icons/components'; import {Channel, ChannelMembership} from '@mattermost/types/channels'; import {RelationOneToOne} from '@mattermost/types/utilities'; import {isPrivateChannel} from 'mattermost-redux/utils/channel_utils'; @@ -46,6 +46,7 @@ type Props = { rememberHideJoinedChannelsChecked: boolean; canShowArchivedChannels?: boolean; loading?: boolean; + channelsMemberCount?: Record; } type State = { @@ -133,6 +134,10 @@ export default class SearchableChannelList extends React.PureComponent; } + let memberCount = 0; + if (this.props.channelsMemberCount?.[channel.id]) { + memberCount = this.props.channelsMemberCount[channel.id]; + } const membershipIndicator = this.isMemberOfChannel(channel.id) ? (

{membershipIndicator} - {(channel.purpose.length > 0 && membershipIndicator) ? : null} + {membershipIndicator ? : null} + + {memberCount} + {channel.purpose.length > 0 ? : null} {channel.purpose}
); @@ -195,6 +203,7 @@ export default class SearchableChannelList extends React.PureComponent this.handleJoin(channel, e)} tabIndex={0} diff --git a/webapp/channels/src/components/sidebar/add_channels_cta_button.tsx b/webapp/channels/src/components/sidebar/add_channels_cta_button.tsx index daa92c0bc0..2152522d68 100644 --- a/webapp/channels/src/components/sidebar/add_channels_cta_button.tsx +++ b/webapp/channels/src/components/sidebar/add_channels_cta_button.tsx @@ -9,7 +9,7 @@ import {useSelector, useDispatch} from 'react-redux'; import MenuWrapper from 'components/widgets/menu/menu_wrapper'; import Menu from 'components/widgets/menu/menu'; -import MoreChannels from 'components/more_channels'; +import BrowseChannels from 'components/browse_channels'; import NewChannelModal from 'components/new_channel_modal/new_channel_modal'; import {isAddChannelCtaDropdownOpen} from 'selectors/views/add_channel_dropdown'; @@ -59,7 +59,7 @@ const AddChannelsCtaButton = (): JSX.Element | null => { const showMoreChannelsModal = () => { dispatch(openModal({ modalId: ModalIdentifiers.MORE_CHANNELS, - dialogType: MoreChannels, + dialogType: BrowseChannels, dialogProps: {morePublicChannelsModalType: 'public'}, })); trackEvent('ui', 'browse_channels_button_is_clicked'); diff --git a/webapp/channels/src/components/sidebar/sidebar.tsx b/webapp/channels/src/components/sidebar/sidebar.tsx index 02b2f6e680..ff5fa01759 100644 --- a/webapp/channels/src/components/sidebar/sidebar.tsx +++ b/webapp/channels/src/components/sidebar/sidebar.tsx @@ -8,7 +8,7 @@ import {trackEvent} from 'actions/telemetry_actions'; import EditCategoryModal from 'components/edit_category_modal'; import MoreDirectChannels from 'components/more_direct_channels'; import DataPrefetch from 'components/data_prefetch'; -import MoreChannels from 'components/more_channels'; +import BrowseChannels from 'components/browse_channels'; import NewChannelModal from 'components/new_channel_modal/new_channel_modal'; import InvitationModal from 'components/invitation_modal'; import UserSettingsModal from 'components/user_settings/modal'; @@ -155,7 +155,7 @@ export default class Sidebar extends React.PureComponent { showMoreChannelsModal = () => { this.props.actions.openModal({ modalId: ModalIdentifiers.MORE_CHANNELS, - dialogType: MoreChannels, + dialogType: BrowseChannels, dialogProps: {morePublicChannelsModalType: 'public'}, }); trackEvent('ui', 'ui_channels_more_public_v2'); diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/channels.ts index 863d651c8c..b3120b761b 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/channels.ts @@ -56,6 +56,7 @@ export default keyMirror({ RECEIVED_CHANNEL_MEMBERS: null, RECEIVED_CHANNEL_MEMBER: null, RECEIVED_CHANNEL_STATS: null, + RECEIVED_CHANNELS_MEMBER_COUNT: null, RECEIVED_CHANNEL_PROPS: null, RECEIVED_CHANNEL_DELETED: null, RECEIVED_CHANNEL_UNARCHIVED: null, 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 f31c122014..72c422735e 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -1083,6 +1083,27 @@ export function getChannelStats(channelId: string, excludeFilesCount?: boolean): }; } +export function getChannelsMemberCount(channelIds: string[]): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + let channelsMemberCount; + + try { + channelsMemberCount = await Client4.getChannelsMemberCount(channelIds); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch({ + type: ChannelTypes.RECEIVED_CHANNELS_MEMBER_COUNT, + data: channelsMemberCount, + }); + + return {data: channelsMemberCount}; + }; +} + export function addChannelMember(channelId: string, userId: string, postRootId = ''): ActionFunc { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let member; 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 6c378a660f..27819a4be9 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 @@ -713,6 +713,22 @@ function stats(state: RelationOneToOne = {}, action: Gene } } +function channelsMemberCount(state: Record = {}, action: GenericAction) { + switch (action.type) { + case ChannelTypes.RECEIVED_CHANNELS_MEMBER_COUNT: { + const memberCount = action.data; + return { + ...state, + ...memberCount, + }; + } + case UserTypes.LOGOUT_SUCCESS: + return {}; + default: + return state; + } +} + function groupsAssociatedToChannel(state: any = {}, action: GenericAction) { switch (action.type) { case GroupTypes.RECEIVED_ALL_GROUPS_ASSOCIATED_TO_CHANNELS_IN_TEAM: { @@ -989,4 +1005,7 @@ export default combineReducers({ // object where every key is the channel id mapping to an object containing the number of messages in the channel messageCounts, + + // object where key is the channel id and value is the member count for the channel + channelsMemberCount, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts index 97c715baa3..53084b9ad7 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/channels.ts @@ -98,6 +98,10 @@ export function getAllChannelStats(state: GlobalState): RelationOneToOne { + return state.entities.channels.channelsMemberCount; +} + export function getChannelsInTeam(state: GlobalState): RelationOneToMany { return state.entities.channels.channelsInTeam; } diff --git a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts index ef44c24060..8c27c13f41 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts @@ -58,6 +58,7 @@ const state: GlobalState = { channelModerations: {}, channelMemberCountsByGroup: {}, messageCounts: {}, + channelsMemberCount: {}, }, posts: { expandedURLs: {}, diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index ec34bbe780..915b36a798 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -1755,6 +1755,13 @@ export default class Client4 { ); }; + getChannelsMemberCount = (channelIds: string[]) => { + return this.doFetch>( + `${this.getChannelsRoute()}/stats/member_count`, + {method: 'post', body: JSON.stringify(channelIds)} + ) + } + getChannelModerations = (channelId: string) => { return this.doFetch( `${this.getChannelRoute(channelId)}/moderations`, diff --git a/webapp/platform/types/src/channels.ts b/webapp/platform/types/src/channels.ts index bd5d6e48d0..b03426f3e7 100644 --- a/webapp/platform/types/src/channels.ts +++ b/webapp/platform/types/src/channels.ts @@ -156,6 +156,7 @@ export type ChannelsState = { channelModerations: RelationOneToOne; channelMemberCountsByGroup: RelationOneToOne; messageCounts: RelationOneToOne; + channelsMemberCount: Record; }; export type ChannelModeration = {