diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index 9b2e32d1c4..f4f7be0a1e 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -24,6 +24,7 @@ func (api *API) InitChannel() { api.BaseRoutes.Channels.Handle("/group/search", api.APISessionRequiredDisableWhenBusy(searchGroupChannels)).Methods("POST") 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("/members/{user_id:[A-Za-z0-9]+}/mark_read", api.APISessionRequired(readMultipleChannels)).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") @@ -1537,6 +1538,32 @@ func viewChannel(c *Context, w http.ResponseWriter, r *http.Request) { } } +func readMultipleChannels(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireUserId() + + var channelIDs []string + err := json.NewDecoder(r.Body).Decode(&channelIDs) + if err != nil || len(channelIDs) == 0 { + c.SetInvalidParamWithErr("channel_ids", err) + return + } + + times, appErr := c.App.MarkChannelsAsViewed(c.AppContext, channelIDs, c.Params.UserId, c.AppContext.Session().Id, true, c.App.IsCRTEnabledForUser(c.AppContext, c.Params.UserId)) + if appErr != nil { + c.Err = appErr + return + } + + resp := &model.ChannelViewResponse{ + Status: "OK", + LastViewedAtTimes: times, + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + func updateChannelMemberRoles(c *Context, w http.ResponseWriter, r *http.Request) { c.RequireChannelId().RequireUserId() if c.Err != nil { diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index 412e3a0169..678726790e 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -2478,9 +2478,10 @@ func TestViewChannel(t *testing.T) { CheckBadRequestStatus(t, resp) view.ChannelId = "correctlysizedjunkdddfdfdf" - _, resp, err = client.ViewChannel(context.Background(), th.BasicUser.Id, view) - require.Error(t, err) - CheckBadRequestStatus(t, resp) + viewResult, _, err := client.ViewChannel(context.Background(), th.BasicUser.Id, view) + require.NoError(t, err) + require.Len(t, viewResult.LastViewedAtTimes, 0) + view.ChannelId = th.BasicChannel.Id member, _, err := client.GetChannelMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id, "") diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index e8def4b305..80998a845f 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -2970,57 +2970,32 @@ func (a *App) SearchChannelsUserNotIn(c request.CTX, teamID string, userID strin } func (a *App) MarkChannelsAsViewed(c request.CTX, channelIDs []string, userID string, currentSessionId string, collapsedThreadsSupported, isCRTEnabled bool) (map[string]int64, *model.AppError) { - // I start looking for channels with notifications before I mark it as read, to clear the push notifications if needed - channelsToClearPushNotifications := []string{} - if a.canSendPushNotifications() { - for _, channelID := range channelIDs { - channel, errCh := a.Srv().Store().Channel().Get(channelID, true) - if errCh != nil { - c.Logger().Warn("Failed to get channel", mlog.Err(errCh)) - continue - } + var err error - member, err := a.Srv().Store().Channel().GetMember(context.Background(), channelID, userID) - if err != nil { - c.Logger().Warn("Failed to get membership", mlog.Err(err)) - continue - } - - notify := member.NotifyProps[model.PushNotifyProp] - if notify == model.ChannelNotifyDefault { - user, err := a.GetUser(userID) - if err != nil { - c.Logger().Warn("Failed to get user", mlog.String("user_id", userID), mlog.Err(err)) - continue - } - notify = user.NotifyProps[model.PushNotifyProp] - } - if notify == model.UserNotifyAll { - if count, err := a.Srv().Store().User().GetAnyUnreadPostCountForChannel(userID, channelID); err == nil { - if count > 0 { - channelsToClearPushNotifications = append(channelsToClearPushNotifications, channelID) - } - } - } else if notify == model.UserNotifyMention || channel.Type == model.ChannelTypeDirect { - if count, err := a.Srv().Store().User().GetUnreadCountForChannel(userID, channelID); err == nil { - if count > 0 { - channelsToClearPushNotifications = append(channelsToClearPushNotifications, channelID) - } - } - } - } + user, err := a.Srv().Store().User().Get(c.Context(), userID) + if err != nil { + return nil, model.NewAppError("MarkChannelsAsViewed", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // We use channelsToView to later only update those, or early return if no channel is to be read + channelsToView, channelsToClearPushNotifications, times, err := a.Srv().Store().Channel().GetChannelsWithUnreadsAndWithMentions(c.Context(), channelIDs, userID, user.NotifyProps) + if err != nil { + return nil, model.NewAppError("MarkChannelsAsViewed", "app.channel.get_channels_with_unreads_and_with_mentions.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + if len(channelsToView) == 0 { + return times, nil } - var err error updateThreads := *a.Config().ServiceSettings.ThreadAutoFollow && (!collapsedThreadsSupported || !isCRTEnabled) if updateThreads { - err = a.Srv().Store().Thread().MarkAllAsReadByChannels(userID, channelIDs) + err = a.Srv().Store().Thread().MarkAllAsReadByChannels(userID, channelsToView) if err != nil { - return nil, model.NewAppError("MarkChannelsAsViewed", "app.channel.update_last_viewed_at.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return nil, model.NewAppError("MarkChannelsAsViewed", "app.thread.mark_all_as_read_by_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } } - times, err := a.Srv().Store().Channel().UpdateLastViewedAt(channelIDs, userID) + _, err = a.Srv().Store().Channel().UpdateLastViewedAt(channelsToView, userID) if err != nil { var invErr *store.ErrInvalidInput switch { @@ -3032,19 +3007,18 @@ func (a *App) MarkChannelsAsViewed(c request.CTX, channelIDs []string, userID st } if *a.Config().ServiceSettings.EnableChannelViewedMessages { - for _, channelID := range channelIDs { - message := model.NewWebSocketEvent(model.WebsocketEventChannelViewed, "", "", userID, nil, "") - message.Add("channel_id", channelID) - a.Publish(message) - } + message := model.NewWebSocketEvent(model.WebsocketEventMultipleChannelsViewed, "", "", userID, nil, "") + message.Add("channel_times", times) + a.Publish(message) } + for _, channelID := range channelsToClearPushNotifications { a.clearPushNotification(currentSessionId, userID, channelID, "") } if updateThreads && isCRTEnabled { timestamp := model.GetMillis() - for _, channelID := range channelIDs { + for _, channelID := range channelsToView { message := model.NewWebSocketEvent(model.WebsocketEventThreadReadChanged, "", channelID, userID, nil, "") message.Add("timestamp", timestamp) a.Publish(message) diff --git a/server/channels/app/channel_test.go b/server/channels/app/channel_test.go index a6c3c57ad4..767bdf8fdd 100644 --- a/server/channels/app/channel_test.go +++ b/server/channels/app/channel_test.go @@ -7,7 +7,6 @@ import ( "context" "errors" "fmt" - "net/http" "sort" "strings" "sync" @@ -18,7 +17,6 @@ import ( "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/v8/channels/app/users" "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks" ) @@ -2107,48 +2105,6 @@ func TestPatchChannelModerationsForChannel(t *testing.T) { }) } -// TestMarkChannelsAsViewedPanic verifies that returning an error from a.GetUser -// does not cause a panic. -func TestMarkChannelsAsViewedPanic(t *testing.T) { - th := SetupWithStoreMock(t) - defer th.TearDown() - - mockStore := th.App.Srv().Store().(*mocks.Store) - mockUserStore := mocks.UserStore{} - mockUserStore.On("Get", context.Background(), "userID").Return(nil, model.NewAppError("SqlUserStore.Get", "app.user.get.app_error", nil, "user_id=userID", http.StatusInternalServerError)) - mockChannelStore := mocks.ChannelStore{} - mockChannelStore.On("Get", "channelID", true).Return(&model.Channel{}, nil) - mockChannelStore.On("GetMember", context.Background(), "channelID", "userID").Return(&model.ChannelMember{ - NotifyProps: model.StringMap{ - model.PushNotifyProp: model.ChannelNotifyDefault, - }}, nil) - times := map[string]int64{ - "userID": 1, - } - mockChannelStore.On("UpdateLastViewedAt", []string{"channelID"}, "userID").Return(times, nil) - mockSessionStore := mocks.SessionStore{} - mockOAuthStore := mocks.OAuthStore{} - var err error - th.App.ch.srv.userService, err = users.New(users.ServiceConfig{ - UserStore: &mockUserStore, - SessionStore: &mockSessionStore, - OAuthStore: &mockOAuthStore, - ConfigFn: th.App.ch.srv.platform.Config, - LicenseFn: th.App.ch.srv.License, - }) - require.NoError(t, err) - mockPreferenceStore := mocks.PreferenceStore{} - mockPreferenceStore.On("Get", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(&model.Preference{Value: "test"}, nil) - mockStore.On("Channel").Return(&mockChannelStore) - mockStore.On("Preference").Return(&mockPreferenceStore) - mockThreadStore := mocks.ThreadStore{} - mockThreadStore.On("MarkAllAsReadByChannels", "userID", []string{"channelID"}).Return(nil) - mockStore.On("Thread").Return(&mockThreadStore) - - _, appErr := th.App.MarkChannelsAsViewed(th.Context, []string{"channelID"}, "userID", th.Context.Session().Id, false, false) - require.Nil(t, appErr) -} - func TestClearChannelMembersCache(t *testing.T) { th := SetupWithStoreMock(t) defer th.TearDown() diff --git a/server/channels/app/platform/web_conn.go b/server/channels/app/platform/web_conn.go index 5994f500a1..55837d27e5 100644 --- a/server/channels/app/platform/web_conn.go +++ b/server/channels/app/platform/web_conn.go @@ -726,7 +726,7 @@ func (wc *WebConn) ShouldSendEvent(msg *model.WebSocketEvent) bool { switch msg.EventType() { case model.WebsocketEventTyping, model.WebsocketEventStatusChange, - model.WebsocketEventChannelViewed: + model.WebsocketEventMultipleChannelsViewed: if time.Since(wc.lastLogTimeSlow) > websocketSuppressWarnThreshold { mlog.Warn( "websocket.slow: dropping message", diff --git a/server/channels/app/post_test.go b/server/channels/app/post_test.go index 6c1110675e..123025bd8e 100644 --- a/server/channels/app/post_test.go +++ b/server/channels/app/post_test.go @@ -1113,26 +1113,6 @@ func TestCreatePostAsUser(t *testing.T) { require.Equal(t, channelMemberAfter.LastViewedAt, channelMemberBefore.LastViewedAt) }) - t.Run("logs warning for user not in channel", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - user := th.CreateUser() - th.LinkUserToTeam(user, th.BasicTeam) - - post := &model.Post{ - ChannelId: th.BasicChannel.Id, - Message: "test", - UserId: user.Id, - } - - _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) - require.Nil(t, appErr) - - require.NoError(t, th.TestLogger.Flush()) - - testlib.AssertLog(t, th.LogBuffer, mlog.LvlWarn.Name, "Failed to get membership") - }) - t.Run("does not log warning for bot user not in channel", func(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 0ab2b248fd..0a3f313d28 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -1322,6 +1322,24 @@ func (s *OpenTracingLayerChannelStore) GetChannelsWithTeamDataByIds(channelIds [ return result, err } +func (s *OpenTracingLayerChannelStore) GetChannelsWithUnreadsAndWithMentions(ctx context.Context, channelIDs []string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.GetChannelsWithUnreadsAndWithMentions") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, resultVar1, resultVar2, err := s.ChannelStore.GetChannelsWithUnreadsAndWithMentions(ctx, channelIDs, userID, userNotifyProps) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, resultVar1, resultVar2, err +} + func (s *OpenTracingLayerChannelStore) GetDeleted(team_id string, offset int, limit int, userID string) (model.ChannelList, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.GetDeleted") diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 3f965768e9..3e222a5c5c 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -1468,6 +1468,27 @@ func (s *RetryLayerChannelStore) GetChannelsWithTeamDataByIds(channelIds []strin } +func (s *RetryLayerChannelStore) GetChannelsWithUnreadsAndWithMentions(ctx context.Context, channelIDs []string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + + tries := 0 + for { + result, resultVar1, resultVar2, err := s.ChannelStore.GetChannelsWithUnreadsAndWithMentions(ctx, channelIDs, userID, userNotifyProps) + if err == nil { + return result, resultVar1, resultVar2, nil + } + if !isRepeatableError(err) { + return result, resultVar1, resultVar2, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, resultVar1, resultVar2, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerChannelStore) GetDeleted(team_id string, offset int, limit int, userID 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 00c9bf31ad..15a2a1e33d 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -2020,6 +2020,82 @@ func (s SqlChannelStore) GetChannelMembersTimezones(channelId string) ([]model.S return dbMembersTimezone, nil } +func (s SqlChannelStore) GetChannelsWithUnreadsAndWithMentions(ctx context.Context, channelIDs []string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + query := s.getQueryBuilder().Select( + "Channels.Id", + "Channels.Type", + "Channels.TotalMsgCount", + "Channels.LastPostAt", + "ChannelMembers.MsgCount", + "ChannelMembers.MentionCount", + "ChannelMembers.NotifyProps", + "ChannelMembers.LastViewedAt", + ). + From("ChannelMembers"). + InnerJoin("Channels ON ChannelMembers.ChannelId = Channels.Id"). + Where(sq.Eq{ + "ChannelMembers.ChannelId": channelIDs, + "ChannelMembers.UserId": userID, + }) + + queryString, args, err := query.ToSql() + if err != nil { + return nil, nil, nil, errors.Wrap(err, "channel_tosql") + } + + var channels []struct { + Id string + Type string + TotalMsgCount int + LastPostAt int64 + MsgCount int + MentionCount int + NotifyProps model.StringMap + LastViewedAt int64 + } + + err = s.GetReplicaX().Select(&channels, queryString, args...) + if err != nil { + return nil, nil, nil, errors.Wrap(err, "failed to find channels with unreads and with mentions data") + } + + channelsWithUnreads := []string{} + channelsWithMentions := []string{} + readTimes := map[string]int64{} + + for i := range channels { + channel := channels[i] + hasMentions := (channel.MentionCount > 0) + hasUnreads := (channel.TotalMsgCount-channel.MsgCount > 0) || hasMentions + + if hasUnreads { + channelsWithUnreads = append(channelsWithUnreads, channel.Id) + } + + notify := channel.NotifyProps[model.PushNotifyProp] + if notify == model.ChannelNotifyDefault { + notify = userNotifyProps[model.PushNotifyProp] + } + if notify == model.UserNotifyAll || channel.Type == string(model.ChannelTypeDirect) { + if hasUnreads { + channelsWithMentions = append(channelsWithMentions, channel.Id) + } + } else if notify == model.UserNotifyMention { + if hasMentions { + channelsWithMentions = append(channelsWithMentions, channel.Id) + } + } + + if channel.LastPostAt > channel.LastViewedAt { + readTimes[channel.Id] = channel.LastPostAt + } else { + readTimes[channel.Id] = channel.LastViewedAt + } + } + + return channelsWithUnreads, channelsWithMentions, readTimes, nil +} + func (s SqlChannelStore) GetMember(ctx context.Context, channelID string, userID string) (*model.ChannelMember, error) { selectSQL, args, err := s.channelMembersForTeamWithSchemeSelectQuery. Where(sq.Eq{ @@ -2512,6 +2588,10 @@ func (s SqlChannelStore) UpdateLastViewedAt(channelIds []string, userId string) TotalMsgCountRoot int64 }{} + if len(channelIds) == 0 { + return map[string]int64{}, nil + } + // We use the question placeholder format for both databases, because // we replace that with the dollar format later on. // It's needed to support the prefix CTE query. See: https://github.com/Masterminds/squirrel/issues/285. diff --git a/server/channels/store/store.go b/server/channels/store/store.go index d6611e6f23..347b2e1809 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -265,6 +265,7 @@ type ChannelStore interface { GetMembersInfoByChannelIds(channelIDs []string) (map[string][]*model.User, error) AnalyticsDeletedTypeCount(teamID string, channelType model.ChannelType) (int64, error) GetChannelUnread(channelID, userID string) (*model.ChannelUnread, error) + GetChannelsWithUnreadsAndWithMentions(ctx context.Context, channelIDs []string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) ClearCaches() ClearMembersForUserCache() GetChannelsByScheme(schemeID string, offset int, limit int) (model.ChannelList, error) diff --git a/server/channels/store/storetest/channel_store.go b/server/channels/store/storetest/channel_store.go index 70c27e9e95..b1f74994b8 100644 --- a/server/channels/store/storetest/channel_store.go +++ b/server/channels/store/storetest/channel_store.go @@ -8,6 +8,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "sort" "strconv" "strings" @@ -149,6 +150,7 @@ func TestChannelStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("UpdateSidebarChannelsByPreferences", func(t *testing.T) { testUpdateSidebarChannelsByPreferences(t, ss) }) t.Run("SetShared", func(t *testing.T) { testSetShared(t, ss) }) t.Run("GetTeamForChannel", func(t *testing.T) { testGetTeamForChannel(t, ss) }) + t.Run("GetChannelsWithUnreadsAndWithMentions", func(t *testing.T) { testGetChannelsWithUnreadsAndWithMentions(t, ss) }) } func testChannelStoreSave(t *testing.T, ss store.Store) { @@ -8065,3 +8067,166 @@ func testGetTeamForChannel(t *testing.T, ss store.Store) { var nfErr *store.ErrNotFound require.True(t, errors.As(err, &nfErr)) } + +func testGetChannelsWithUnreadsAndWithMentions(t *testing.T, ss store.Store) { + setupMembership := func( + pushProp string, + withUnreads bool, + withMentions bool, + isDirect bool, + userId string, + ) (model.Channel, model.ChannelMember) { + if !isDirect { + o1 := model.Channel{} + o1.TeamId = model.NewId() + o1.DisplayName = "Channel1" + o1.Name = NewTestId() + o1.Type = model.ChannelTypeOpen + o1.TotalMsgCount = 25 + o1.LastPostAt = 12345 + o1.LastRootPostAt = 12345 + _, nErr := ss.Channel().Save(&o1, -1) + require.NoError(t, nErr) + + m1 := model.ChannelMember{} + m1.ChannelId = o1.Id + m1.UserId = userId + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + m1.NotifyProps[model.PushNotifyProp] = pushProp + if !withUnreads { + m1.MsgCount = o1.TotalMsgCount + m1.LastViewedAt = o1.LastPostAt + } + if withMentions { + m1.MentionCount = 5 + } + _, err := ss.Channel().SaveMember(&m1) + require.NoError(t, err) + + return o1, m1 + } + + o1, err := ss.Channel().CreateDirectChannel(&model.User{Id: userId}, &model.User{Id: model.NewId()}, func(channel *model.Channel) { + channel.TotalMsgCount = 25 + channel.LastPostAt = 12345 + channel.LastRootPostAt = 12345 + }) + require.NoError(t, err) + + m1, err := ss.Channel().GetMember(context.Background(), o1.Id, userId) + require.NoError(t, err) + + if !withUnreads { + m1.MsgCount = o1.TotalMsgCount + m1.LastViewedAt = o1.LastPostAt + } + if withMentions { + m1.MentionCount = 5 + } + + m1, err = ss.Channel().UpdateMember(m1) + require.NoError(t, err) + + return *o1, *m1 + } + + type TestCase struct { + name string + pushProp string + userNotifyProp string + isDirect bool + withUnreads bool + withMentions bool + } + ttcc := []TestCase{} + + channelNotifyProps := []string{model.ChannelNotifyDefault, model.ChannelNotifyAll, model.ChannelNotifyMention, model.ChannelNotifyNone} + userNotifyProps := []string{model.UserNotifyAll, model.UserNotifyMention, model.UserNotifyHere, model.UserNotifyNone} + boolRange := []bool{true, false} + + nameTemplate := "pushProp: %s, userPushProp: %s, direct: %t, unreads: %t, mentions: %t" + for _, pushProp := range channelNotifyProps { + for _, userNotifyProp := range userNotifyProps { + for _, isDirect := range boolRange { + for _, withUnreads := range boolRange { + ttcc = append(ttcc, TestCase{ + name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, isDirect, withUnreads, false), + pushProp: pushProp, + userNotifyProp: userNotifyProp, + isDirect: isDirect, + withUnreads: withUnreads, + withMentions: false, + }) + if withUnreads { + ttcc = append(ttcc, TestCase{ + name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, isDirect, withUnreads, true), + pushProp: pushProp, + userNotifyProp: userNotifyProp, + isDirect: isDirect, + withUnreads: withUnreads, + withMentions: true, + }) + } + } + } + } + } + + for _, tc := range ttcc { + t.Run(tc.name, func(t *testing.T) { + o1, m1 := setupMembership(tc.pushProp, tc.withUnreads, tc.withMentions, tc.isDirect, model.NewId()) + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = tc.userNotifyProp + unreads, mentions, times, err := ss.Channel().GetChannelsWithUnreadsAndWithMentions(context.Background(), []string{o1.Id}, m1.UserId, userNotifyProps) + require.NoError(t, err) + + expectedUnreadsLength := 0 + if tc.withUnreads { + expectedUnreadsLength = 1 + } + require.Len(t, unreads, expectedUnreadsLength) + + propToUse := tc.pushProp + if tc.pushProp == model.ChannelNotifyDefault { + propToUse = tc.userNotifyProp + } + expectedMentionsLength := 0 + if (tc.isDirect && tc.withUnreads) || (propToUse == model.UserNotifyAll && tc.withUnreads) || (propToUse == model.UserNotifyMention && tc.withMentions) { + expectedMentionsLength = 1 + } + + require.Len(t, mentions, expectedMentionsLength) + require.Equal(t, o1.LastPostAt, times[o1.Id]) + }) + } + + t.Run("multiple channels", func(t *testing.T) { + userId := model.NewId() + o1, _ := setupMembership(model.ChannelNotifyDefault, true, true, false, userId) + o2, _ := setupMembership(model.ChannelNotifyDefault, true, true, false, userId) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetChannelsWithUnreadsAndWithMentions(context.Background(), []string{o1.Id, o2.Id}, userId, userNotifyProps) + require.NoError(t, err) + + require.Contains(t, unreads, o1.Id) + require.Contains(t, unreads, o2.Id) + require.Contains(t, mentions, o1.Id) + require.Contains(t, mentions, o2.Id) + require.Equal(t, o1.LastPostAt, times[o1.Id]) + require.Equal(t, o2.LastPostAt, times[o2.Id]) + }) + + t.Run("non existing channel", func(t *testing.T) { + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + unreads, mentions, times, err := ss.Channel().GetChannelsWithUnreadsAndWithMentions(context.Background(), []string{"foo"}, "foo", userNotifyProps) + require.NoError(t, err) + + require.Len(t, unreads, 0) + require.Len(t, mentions, 0) + require.Len(t, times, 0) + }) +} diff --git a/server/channels/store/storetest/mocks/ChannelStore.go b/server/channels/store/storetest/mocks/ChannelStore.go index 4d3500da1c..1fe609b4b7 100644 --- a/server/channels/store/storetest/mocks/ChannelStore.go +++ b/server/channels/store/storetest/mocks/ChannelStore.go @@ -986,6 +986,50 @@ func (_m *ChannelStore) GetChannelsWithTeamDataByIds(channelIds []string, includ return r0, r1 } +// GetChannelsWithUnreadsAndWithMentions provides a mock function with given fields: ctx, channelIDs, userID, userNotifyProps +func (_m *ChannelStore) GetChannelsWithUnreadsAndWithMentions(ctx context.Context, channelIDs []string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + ret := _m.Called(ctx, channelIDs, userID, userNotifyProps) + + var r0 []string + var r1 []string + var r2 map[string]int64 + var r3 error + if rf, ok := ret.Get(0).(func(context.Context, []string, string, model.StringMap) ([]string, []string, map[string]int64, error)); ok { + return rf(ctx, channelIDs, userID, userNotifyProps) + } + if rf, ok := ret.Get(0).(func(context.Context, []string, string, model.StringMap) []string); ok { + r0 = rf(ctx, channelIDs, userID, userNotifyProps) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, []string, string, model.StringMap) []string); ok { + r1 = rf(ctx, channelIDs, userID, userNotifyProps) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]string) + } + } + + if rf, ok := ret.Get(2).(func(context.Context, []string, string, model.StringMap) map[string]int64); ok { + r2 = rf(ctx, channelIDs, userID, userNotifyProps) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(map[string]int64) + } + } + + if rf, ok := ret.Get(3).(func(context.Context, []string, string, model.StringMap) error); ok { + r3 = rf(ctx, channelIDs, userID, userNotifyProps) + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} + // GetDeleted provides a mock function with given fields: team_id, offset, limit, userID func (_m *ChannelStore) GetDeleted(team_id string, offset int, limit int, userID string) (model.ChannelList, error) { ret := _m.Called(team_id, offset, limit, userID) diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index e12927a33c..2fdaf460d1 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -1232,6 +1232,22 @@ func (s *TimerLayerChannelStore) GetChannelsWithTeamDataByIds(channelIds []strin return result, err } +func (s *TimerLayerChannelStore) GetChannelsWithUnreadsAndWithMentions(ctx context.Context, channelIDs []string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + start := time.Now() + + result, resultVar1, resultVar2, err := s.ChannelStore.GetChannelsWithUnreadsAndWithMentions(ctx, channelIDs, userID, userNotifyProps) + + 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.GetChannelsWithUnreadsAndWithMentions", success, elapsed) + } + return result, resultVar1, resultVar2, err +} + func (s *TimerLayerChannelStore) GetDeleted(team_id string, offset int, limit int, userID string) (model.ChannelList, error) { start := time.Now() diff --git a/server/i18n/en.json b/server/i18n/en.json index 3aeec89829..86f04cd228 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -4783,6 +4783,10 @@ "id": "app.channel.get_channels_member_count.find.app_error", "translation": "Unable to find member count." }, + { + "id": "app.channel.get_channels_with_unreads_and_with_mentions.app_error", + "translation": "Unable to check unreads and mentions" + }, { "id": "app.channel.get_deleted.existing.app_error", "translation": "Unable to find the existing deleted channel." @@ -6743,6 +6747,10 @@ "id": "app.terms_of_service.get.no_rows.app_error", "translation": "No terms of service found." }, + { + "id": "app.thread.mark_all_as_read_by_channels.app_error", + "translation": "Unable to mark all threads as read by channel" + }, { "id": "app.update_error", "translation": "update error" diff --git a/server/public/model/client4.go b/server/public/model/client4.go index af991fde8c..f8a251bfa4 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -3540,6 +3540,27 @@ func (c *Client4) ViewChannel(ctx context.Context, userId string, view *ChannelV return ch, BuildResponse(r), nil } +// ReadMultipleChannels performs a view action on several channels at the same time for a user. +func (c *Client4) ReadMultipleChannels(ctx context.Context, userId string, channelIds []string) (*ChannelViewResponse, *Response, error) { + url := fmt.Sprintf(c.channelsRoute()+"/members/%v/mark_read", userId) + buf, err := json.Marshal(channelIds) + if err != nil { + return nil, nil, NewAppError("ReadMultipleChannels", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + r, err := c.DoAPIPostBytes(ctx, url, buf) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var ch *ChannelViewResponse + err = json.NewDecoder(r.Body).Decode(&ch) + if err != nil { + return nil, BuildResponse(r), NewAppError("ReadMultipleChannels", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return ch, BuildResponse(r), nil +} + // GetChannelUnread will return a ChannelUnread object that contains the number of // unread messages and mentions for a user. func (c *Client4) GetChannelUnread(ctx context.Context, channelId, userId string) (*ChannelUnread, *Response, error) { diff --git a/server/public/model/websocket_message.go b/server/public/model/websocket_message.go index 41cfc51896..6a8bf5ae31 100644 --- a/server/public/model/websocket_message.go +++ b/server/public/model/websocket_message.go @@ -48,6 +48,7 @@ const ( WebsocketEventResponse = "response" WebsocketEventEmojiAdded = "emoji_added" WebsocketEventChannelViewed = "channel_viewed" + WebsocketEventMultipleChannelsViewed = "multiple_channels_viewed" WebsocketEventPluginStatusesChanged = "plugin_statuses_changed" WebsocketEventPluginEnabled = "plugin_enabled" WebsocketEventPluginDisabled = "plugin_disabled" diff --git a/webapp/channels/src/actions/new_post.test.ts b/webapp/channels/src/actions/new_post.test.ts index 2ef6a7d7d5..6944be7408 100644 --- a/webapp/channels/src/actions/new_post.test.ts +++ b/webapp/channels/src/actions/new_post.test.ts @@ -174,7 +174,7 @@ describe('actions/new_post', () => { }, }, { - type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, + type: ChannelTypes.RECEIVED_LAST_VIEWED_AT, data: { channel_id: channelId, }, @@ -377,7 +377,7 @@ describe('actions/new_post', () => { }, }, { - type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, + type: ChannelTypes.RECEIVED_LAST_VIEWED_AT, data: { channel_id: channelId, }, diff --git a/webapp/channels/src/actions/new_post.ts b/webapp/channels/src/actions/new_post.ts index 8a0be88e60..8a5fa18b09 100644 --- a/webapp/channels/src/actions/new_post.ts +++ b/webapp/channels/src/actions/new_post.ts @@ -9,7 +9,6 @@ import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/pre import { actionsToMarkChannelAsRead, actionsToMarkChannelAsUnread, - actionsToMarkChannelAsViewed, markChannelAsViewedOnServer, } from 'mattermost-redux/actions/channels'; import * as PostActions from 'mattermost-redux/actions/posts'; @@ -123,10 +122,7 @@ export function setChannelReadAndViewed(dispatch: DispatchFunc, getState: GetSta dispatch(markChannelAsViewedOnServer(post.channel_id)); } - return [ - ...actionsToMarkChannelAsRead(getState, post.channel_id), - ...actionsToMarkChannelAsViewed(getState, post.channel_id), - ]; + return actionsToMarkChannelAsRead(getState, post.channel_id); } return actionsToMarkChannelAsUnread(getState, websocketMessageProps.team_id, post.channel_id, websocketMessageProps.mentions, fetchedChannelMember, post.root_id === '', post?.metadata?.priority?.priority); diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index 79388b0a49..9d0a568542 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -27,8 +27,7 @@ import { getChannelAndMyMember, getMyChannelMember, getChannelStats, - viewChannel, - markChannelAsRead, + markMultipleChannelsAsRead, getChannelMemberCountsByGroup, } from 'mattermost-redux/actions/channels'; import {getCloudSubscription} from 'mattermost-redux/actions/cloud'; @@ -456,8 +455,8 @@ export function handleEvent(msg) { handleAddEmoji(msg); break; - case SocketEvents.CHANNEL_VIEWED: - handleChannelViewedEvent(msg); + case SocketEvents.MULTIPLE_CHANNELS_VIEWED: + handleMultipleChannelsViewedEvent(msg); break; case SocketEvents.PLUGIN_ENABLED: @@ -741,15 +740,6 @@ export function handlePostEditEvent(msg) { dispatch(receivedPost(post, crtEnabled)); getProfilesAndStatusesForPosts([post], dispatch, getState); - const currentChannelId = getCurrentChannelId(getState()); - - // Update channel state - if (currentChannelId === msg.broadcast.channel_id) { - dispatch(getChannelStats(currentChannelId)); - if (window.isActive) { - dispatch(viewChannel(currentChannelId)); - } - } } async function handlePostDeleteEvent(msg) { @@ -1291,11 +1281,9 @@ function handleReactionRemovedEvent(msg) { }); } -function handleChannelViewedEvent(msg) { - // Useful for when multiple devices have the app open to different channels - if ((!window.isActive || getCurrentChannelId(getState()) !== msg.data.channel_id) && - getCurrentUserId(getState()) === msg.broadcast.user_id) { - dispatch(markChannelAsRead(msg.data.channel_id, '', false)); +function handleMultipleChannelsViewedEvent(msg) { + if (getCurrentUserId(getState()) === msg.broadcast.user_id) { + dispatch(markMultipleChannelsAsRead(msg.data.channel_times)); } } diff --git a/webapp/channels/src/components/logged_in/index.ts b/webapp/channels/src/components/logged_in/index.ts index 01a2a6b17a..564d06237e 100644 --- a/webapp/channels/src/components/logged_in/index.ts +++ b/webapp/channels/src/components/logged_in/index.ts @@ -11,8 +11,8 @@ import {Channel} from '@mattermost/types/channels'; import {DispatchFunc, GenericAction} from 'mattermost-redux/types/actions'; import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone'; -import {viewChannel} from 'mattermost-redux/actions/channels'; -import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {markChannelAsViewedOnServer, updateApproximateViewTime} from 'mattermost-redux/actions/channels'; +import {getCurrentChannelId, isManuallyUnread} from 'mattermost-redux/selectors/entities/channels'; import {getLicense, getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentUser, shouldShowTermsOfService} from 'mattermost-redux/selectors/entities/users'; @@ -34,10 +34,12 @@ function mapStateToProps(state: GlobalState, ownProps: Props) { const license = getLicense(state); const config = getConfig(state); const showTermsOfService = shouldShowTermsOfService(state); + const currentChannelId = getCurrentChannelId(state); return { currentUser: getCurrentUser(state), - currentChannelId: getCurrentChannelId(state), + currentChannelId, + isCurrentChannelManuallyUnread: isManuallyUnread(state, currentChannelId), mfaRequired: checkIfMFARequired(getCurrentUser(state), license, config, ownProps.match.url), enableTimezone: config.ExperimentalTimezone === 'true', showTermsOfService, @@ -60,7 +62,8 @@ function mapDispatchToProps(dispatch: Dispatch) { actions: bindActionCreators({ autoUpdateTimezone, getChannelURLAction, - viewChannel, + markChannelAsViewedOnServer, + updateApproximateViewTime, }, dispatch), }; } diff --git a/webapp/channels/src/components/logged_in/logged_in.test.tsx b/webapp/channels/src/components/logged_in/logged_in.test.tsx index 2f7f3dd465..401b544f38 100644 --- a/webapp/channels/src/components/logged_in/logged_in.test.tsx +++ b/webapp/channels/src/components/logged_in/logged_in.test.tsx @@ -24,8 +24,10 @@ describe('components/logged_in/LoggedIn', () => { actions: { autoUpdateTimezone: jest.fn(), getChannelURLAction: jest.fn(), - viewChannel: jest.fn(), + markChannelAsViewedOnServer: jest.fn(), + updateApproximateViewTime: jest.fn(), }, + isCurrentChannelManuallyUnread: false, showTermsOfService: false, location: { pathname: '/', diff --git a/webapp/channels/src/components/logged_in/logged_in.tsx b/webapp/channels/src/components/logged_in/logged_in.tsx index 01c141e8a7..e6c7b3b601 100644 --- a/webapp/channels/src/components/logged_in/logged_in.tsx +++ b/webapp/channels/src/components/logged_in/logged_in.tsx @@ -29,13 +29,15 @@ declare global { export type Props = { currentUser?: UserProfile; currentChannelId?: string; + isCurrentChannelManuallyUnread: boolean; children?: React.ReactNode; mfaRequired: boolean; enableTimezone: boolean; actions: { autoUpdateTimezone: (deviceTimezone: string) => void; getChannelURLAction: (channel: Channel, teamId: string, url: string) => void; - viewChannel: (channelId: string, prevChannelId?: string) => void; + markChannelAsViewedOnServer: (channelId: string) => void; + updateApproximateViewTime: (channelId: string) => void; }; showTermsOfService: boolean; location: { @@ -226,8 +228,9 @@ export default class LoggedIn extends React.PureComponent { private handleBeforeUnload = (): void => { // remove the event listener to prevent getting stuck in a loop window.removeEventListener('beforeunload', this.handleBeforeUnload); - if (document.cookie.indexOf('MMUSERID=') > -1) { - this.props.actions.viewChannel('', this.props.currentChannelId || ''); + if (document.cookie.indexOf('MMUSERID=') > -1 && this.props.currentChannelId && !this.props.isCurrentChannelManuallyUnread) { + this.props.actions.updateApproximateViewTime(this.props.currentChannelId); + this.props.actions.markChannelAsViewedOnServer(this.props.currentChannelId); } WebSocketActions.close(); }; diff --git a/webapp/channels/src/components/post_view/post_list/index.tsx b/webapp/channels/src/components/post_view/post_list/index.tsx index f9206ccf87..3d532d1eef 100644 --- a/webapp/channels/src/components/post_view/post_list/index.tsx +++ b/webapp/channels/src/components/post_view/post_list/index.tsx @@ -7,7 +7,7 @@ import {Dispatch, bindActionCreators, ActionCreatorsMapObject} from 'redux'; import {getRecentPostsChunkInChannel, makeGetPostsChunkAroundPost, getUnreadPostsChunk, getPost, isPostsChunkIncludingUnreadsPosts, getLimitedViews} from 'mattermost-redux/selectors/entities/posts'; import {memoizeResult} from 'mattermost-redux/utils/helpers'; import {Action} from 'mattermost-redux/types/actions'; -import {markChannelAsRead, markChannelAsViewed} from 'mattermost-redux/actions/channels'; +import {markChannelAsRead} from 'mattermost-redux/actions/channels'; import {makePreparePostIdsForPostList} from 'mattermost-redux/utils/post_list'; import {RequestStatus} from 'mattermost-redux/constants'; @@ -111,7 +111,6 @@ function mapDispatchToProps(dispatch: Dispatch) { loadPostsAround, checkAndSetMobileView, syncPostsInChannel, - markChannelAsViewed, markChannelAsRead, updateNewMessagesAtInChannel, }, dispatch), diff --git a/webapp/channels/src/components/post_view/post_list/post_list.test.tsx b/webapp/channels/src/components/post_view/post_list/post_list.test.tsx index e63cd12742..0988440576 100644 --- a/webapp/channels/src/components/post_view/post_list/post_list.test.tsx +++ b/webapp/channels/src/components/post_view/post_list/post_list.test.tsx @@ -17,7 +17,6 @@ const actionsProp = { syncPostsInChannel: jest.fn().mockResolvedValue({}), loadLatestPosts: jest.fn().mockImplementation(() => Promise.resolve({atLatestMessage: true, atOldestmessage: true})), checkAndSetMobileView: jest.fn(), - markChannelAsViewed: jest.fn(), markChannelAsRead: jest.fn(), updateNewMessagesAtInChannel: jest.fn(), toggleShouldStartFromBottomWhenUnread: jest.fn(), @@ -250,7 +249,6 @@ describe('components/post_view/post_list', () => { await wrapper.instance().postsOnLoad('undefined'); expect(actionsProp.markChannelAsRead).toHaveBeenCalledWith(baseProps.channelId); - expect(actionsProp.markChannelAsViewed).toHaveBeenCalledWith(baseProps.channelId); }); test('Should not call markChannelAsReadAndViewed as it is a permalink', async () => { const emptyPostList: string[] = []; @@ -261,7 +259,6 @@ describe('components/post_view/post_list', () => { await actionsProp.loadPostsAround(); expect(actionsProp.markChannelAsRead).not.toHaveBeenCalled(); - expect(actionsProp.markChannelAsViewed).not.toHaveBeenCalled(); }); }); }); diff --git a/webapp/channels/src/components/post_view/post_list/post_list.tsx b/webapp/channels/src/components/post_view/post_list/post_list.tsx index 3845733895..4265c3db0c 100644 --- a/webapp/channels/src/components/post_view/post_list/post_list.tsx +++ b/webapp/channels/src/components/post_view/post_list/post_list.tsx @@ -141,8 +141,6 @@ export interface Props { */ loadLatestPosts: (channelId: string) => Promise; - markChannelAsViewed: (channelId: string) => void; - markChannelAsRead: (channelId: string) => void; updateNewMessagesAtInChannel: typeof updateNewMessagesAtInChannel; }; @@ -230,7 +228,9 @@ export default class PostList extends React.PureComponent { } if (!focusedPostId) { - this.markChannelAsReadAndViewed(channelId); + // Posts are marked as read from here to not cause a race when loading posts + // marking channel as read and viewed after calling for posts in channel + this.props.actions.markChannelAsRead(channelId); } if (this.mounted) { @@ -274,13 +274,6 @@ export default class PostList extends React.PureComponent { return {error}; }; - markChannelAsReadAndViewed = (channelId: string) => { - // Posts are marked as read from here to not cause a race when loading posts - // marking channel as read and viewed after calling for posts in channel - this.props.actions.markChannelAsViewed(channelId); - this.props.actions.markChannelAsRead(channelId); - }; - getOldestVisiblePostId = () => { return getOldestPostId(this.props.postListIds || []); }; diff --git a/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap b/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap index b9d27c059f..eb1032bad5 100644 --- a/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap +++ b/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap @@ -27,18 +27,7 @@ exports[`components/sidebar should match snapshot 1`] = ` id="lhsNavigator" role="application" > - +
- +
- +
{ beforeEach(() => { props = { - canGoForward: true, - canGoBack: true, - canJoinPublicChannel: true, - showMoreChannelsModal: jest.fn(), - showCreateUserGroupModal: jest.fn(), - invitePeopleModal: jest.fn(), - showNewChannelModal: jest.fn(), - showCreateCategoryModal: jest.fn(), - handleOpenDirectMessagesModal: jest.fn(), - unreadFilterEnabled: true, - canCreateChannel: true, showUnreadsCategory: true, isQuickSwitcherOpen: false, - userGroupsEnabled: false, - canCreateCustomGroups: true, actions: { openModal: jest.fn(), closeModal: jest.fn(), - goBack: jest.fn(), - goForward: jest.fn(), }, }; }); diff --git a/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx b/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx index b6c2b9316d..ead6f9ef8f 100644 --- a/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx +++ b/webapp/channels/src/components/sidebar/channel_navigator/channel_navigator.tsx @@ -18,26 +18,11 @@ import * as Utils from 'utils/utils'; import ChannelFilter from '../channel_filter'; export type Props = { - canGoForward: boolean; - canGoBack: boolean; - canJoinPublicChannel: boolean; - showMoreChannelsModal: () => void; - showCreateUserGroupModal: () => void; - invitePeopleModal: () => void; - showNewChannelModal: () => void; - showCreateCategoryModal: () => void; - handleOpenDirectMessagesModal: (e: Event) => void; - unreadFilterEnabled: boolean; - canCreateChannel: boolean; showUnreadsCategory: boolean; isQuickSwitcherOpen: boolean; - userGroupsEnabled: boolean; - canCreateCustomGroups: boolean; actions: { openModal:

(modalData: ModalData

) => void; closeModal: (modalId: string) => void; - goBack: () => void; - goForward: () => void; }; }; @@ -100,16 +85,6 @@ export default class ChannelNavigator extends React.PureComponent { } }; - goBack = () => { - trackEvent('ui', 'ui_history_back'); - this.props.actions.goBack(); - }; - - goForward = () => { - trackEvent('ui', 'ui_history_forward'); - this.props.actions.goForward(); - }; - render() { return (

diff --git a/webapp/channels/src/components/sidebar/channel_navigator/index.ts b/webapp/channels/src/components/sidebar/channel_navigator/index.ts index a6fe910079..be4682ab9e 100644 --- a/webapp/channels/src/components/sidebar/channel_navigator/index.ts +++ b/webapp/channels/src/components/sidebar/channel_navigator/index.ts @@ -5,12 +5,9 @@ import {connect} from 'react-redux'; import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux'; import {Action} from 'mattermost-redux/types/actions'; -import {shouldShowUnreadsCategory, isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences'; -import {haveISystemPermission} from 'mattermost-redux/selectors/entities/roles'; -import Permissions from 'mattermost-redux/constants/permissions'; +import {shouldShowUnreadsCategory} from 'mattermost-redux/selectors/entities/preferences'; import {openModal, closeModal} from 'actions/views/modals'; -import {getHistory} from 'utils/browser_history'; import {ModalIdentifiers} from 'utils/constants'; import {isModalOpen} from 'selectors/views/modals'; @@ -19,37 +16,16 @@ import {GlobalState} from 'types/store'; import ChannelNavigator from './channel_navigator'; -// TODO: For Phase 1. Will revisit history in Phase 2 -function goBack() { - return () => { - getHistory().goBack(); - return {data: null}; - }; -} - -function goForward() { - return () => { - getHistory().goForward(); - return {data: null}; - }; -} - function mapStateToProps(state: GlobalState) { - const canCreateCustomGroups = haveISystemPermission(state, {permission: Permissions.CREATE_CUSTOM_GROUP}) && isCustomGroupsEnabled(state); return { - canGoBack: true, // TODO: Phase 1 only - canGoForward: true, showUnreadsCategory: shouldShowUnreadsCategory(state), isQuickSwitcherOpen: isModalOpen(state, ModalIdentifiers.QUICK_SWITCH), - canCreateCustomGroups, }; } type Actions = { openModal:

(modalData: ModalData

) => void; closeModal: (modalId: string) => void; - goBack: () => void; - goForward: () => void; } function mapDispatchToProps(dispatch: Dispatch) { @@ -57,8 +33,6 @@ function mapDispatchToProps(dispatch: Dispatch) { actions: bindActionCreators, Actions>({ openModal, closeModal, - goBack, - goForward, }, dispatch), }; } diff --git a/webapp/channels/src/components/sidebar/index.ts b/webapp/channels/src/components/sidebar/index.ts index b7f7edb346..efc71c9e79 100644 --- a/webapp/channels/src/components/sidebar/index.ts +++ b/webapp/channels/src/components/sidebar/index.ts @@ -5,14 +5,12 @@ import {connect} from 'react-redux'; import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux'; import {fetchMyCategories} from 'mattermost-redux/actions/channel_categories'; -import {Preferences} from 'mattermost-redux/constants'; import Permissions from 'mattermost-redux/constants/permissions'; -import {getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getBool, isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {isCustomGroupsEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {haveICurrentChannelPermission, haveISystemPermission} from 'mattermost-redux/selectors/entities/roles'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import {GenericAction} from 'mattermost-redux/types/actions'; -import {createCategory, clearChannelSelection} from 'actions/views/channel_sidebar'; +import {clearChannelSelection} from 'actions/views/channel_sidebar'; import {isUnreadFilterEnabled} from 'selectors/views/channel_sidebar'; import {closeModal, openModal} from 'actions/views/modals'; import {closeRightHandSide} from 'actions/views/rhs'; @@ -49,13 +47,6 @@ function mapStateToProps(state: GlobalState) { canCreatePublicChannel, canJoinPublicChannel, isOpen: getIsLhsOpen(state), - hasSeenModal: getBool( - state, - Preferences.CATEGORY_WHATS_NEW_MODAL, - Preferences.HAS_SEEN_SIDEBAR_WHATS_NEW_MODAL, - false, - ), - isCloud: getLicense(state).Cloud === 'true', unreadFilterEnabled, isMobileView: getIsMobileView(state), isKeyBoardShortcutModalOpen: isModalOpen(state, ModalIdentifiers.KEYBOARD_SHORTCUTS_MODAL), @@ -68,7 +59,6 @@ function mapStateToProps(state: GlobalState) { type Actions = { fetchMyCategories: (teamId: string) => {data: boolean}; - createCategory: (teamId: string, categoryName: string) => {data: string}; openModal:

(modalData: ModalData

) => void; clearChannelSelection: () => void; closeModal: (modalId: string) => void; @@ -79,7 +69,6 @@ function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators({ clearChannelSelection, - createCategory, fetchMyCategories, openModal, closeModal, diff --git a/webapp/channels/src/components/sidebar/sidebar.tsx b/webapp/channels/src/components/sidebar/sidebar.tsx index ff5fa01759..d25e06f119 100644 --- a/webapp/channels/src/components/sidebar/sidebar.tsx +++ b/webapp/channels/src/components/sidebar/sidebar.tsx @@ -36,16 +36,13 @@ type Props = { canCreatePrivateChannel: boolean; canJoinPublicChannel: boolean; isOpen: boolean; - hasSeenModal: boolean; actions: { fetchMyCategories: (teamId: string) => {data: boolean}; - createCategory: (teamId: string, categoryName: string) => {data: string}; openModal:

(modalData: ModalData

) => void; closeModal: (modalId: string) => void; clearChannelSelection: () => void; closeRightHandSide: () => void; }; - isCloud: boolean; unreadFilterEnabled: boolean; isMobileView: boolean; isKeyBoardShortcutModalOpen: boolean; @@ -148,10 +145,6 @@ export default class Sidebar extends React.PureComponent { trackEvent('ui', 'ui_sidebar_menu_createCategory'); }; - handleCreateCategory = (categoryName: string) => { - this.props.actions.createCategory(this.props.teamId, categoryName); - }; - showMoreChannelsModal = () => { this.props.actions.openModal({ modalId: ModalIdentifiers.MORE_CHANNELS, @@ -265,18 +258,7 @@ export default class Sidebar extends React.PureComponent { className='a11y__region' data-a11y-sort-order='6' > - +

diff --git a/webapp/channels/src/components/sidebar/sidebar_category/__snapshots__/sidebar_category.test.tsx.snap b/webapp/channels/src/components/sidebar/sidebar_category/__snapshots__/sidebar_category.test.tsx.snap index a005df6892..0ce27fb2f7 100644 --- a/webapp/channels/src/components/sidebar/sidebar_category/__snapshots__/sidebar_category.test.tsx.snap +++ b/webapp/channels/src/components/sidebar/sidebar_category/__snapshots__/sidebar_category.test.tsx.snap @@ -33,7 +33,7 @@ exports[`components/sidebar/sidebar_category should match snapshot 2`] = ` muted={false} onClick={[Function]} > - -
- - - + + + + } + leadingElement={ + + } + onClick={[Function]} + /> + + } + leadingElement={ + + } + onClick={[Function]} + /> + + } + leadingElement={ + + } + onClick={[Function]} + /> + + + } + leadingElement={ + + } + menuAriaLabel="Sort submenu" + menuId="sortChannels-test_category_id-menu" + trailingElements={ + + + + + } + > + + } + onClick={[Function]} + /> + + } + onClick={[Function]} + /> + + } + onClick={[Function]} + /> + + + + +`; diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/__snapshots__/sidebar_category_menu.test.tsx.snap b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/__snapshots__/sidebar_category_menu.test.tsx.snap deleted file mode 100644 index 06b73bcd6e..0000000000 --- a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/__snapshots__/sidebar_category_menu.test.tsx.snap +++ /dev/null @@ -1,159 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/sidebar/sidebar_category/sidebar_category_menu should match snapshot and contain correct buttons 1`] = ` -
- , - "class": "SidebarMenu_menuButton", - "id": "SidebarCategoryMenu-Button-test_category_id", - } - } - menuButtonTooltip={ - Object { - "class": "hidden-xs", - "id": "SidebarCategoryMenu-ButtonTooltip-test_category_id", - "text": "Category options", - } - } - > - - } - leadingElement={ - - } - onClick={[Function]} - /> - - } - leadingElement={ - - } - onClick={[Function]} - /> - - } - leadingElement={ - - } - onClick={[Function]} - /> - - - } - leadingElement={ - - } - menuAriaLabel="Sort submenu" - menuId="sortChannels-test_category_id-menu" - trailingElements={ - - - - - } - > - - } - onClick={[Function]} - /> - - } - onClick={[Function]} - /> - - } - onClick={[Function]} - /> - - - - } - leadingElement={ - - } - onClick={[Function]} - /> - -
-`; diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/create_new_category_menu_item.tsx b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/create_new_category_menu_item.tsx new file mode 100644 index 0000000000..a1e16bc1ad --- /dev/null +++ b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/create_new_category_menu_item.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import * as Menu from 'components/menu'; + +import {FolderPlusOutlineIcon} from '@mattermost/compass-icons/components'; +import {FormattedMessage} from 'react-intl'; +import {trackEvent} from 'actions/telemetry_actions'; +import {ModalIdentifiers} from 'utils/constants'; + +import EditCategoryModal from 'components/edit_category_modal'; +import {useDispatch} from 'react-redux'; +import {openModal} from 'actions/views/modals'; + +type Props = { + id: string; +} + +const CreateNewCategoryMenuItem = ({ + id, +}: Props) => { + const dispatch = useDispatch(); + const handleCreateCategory = useCallback(() => { + dispatch(openModal({ + modalId: ModalIdentifiers.EDIT_CATEGORY, + dialogType: EditCategoryModal, + })); + trackEvent('ui', 'ui_sidebar_category_menu_createCategory'); + }, [dispatch]); + + return ( + } + labels={( + + )} + /> + ); +}; + +export default CreateNewCategoryMenuItem; diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/sidebar_category_menu.test.tsx b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/index.test.tsx similarity index 73% rename from webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/sidebar_category_menu.test.tsx rename to webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/index.test.tsx index 55475ce058..8c53a217b7 100644 --- a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/sidebar_category_menu.test.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/index.test.tsx @@ -8,7 +8,41 @@ import {CategorySorting} from '@mattermost/types/channel_categories'; import {CategoryTypes} from 'mattermost-redux/constants/channel_categories'; -import SidebarCategoryMenu from './sidebar_category_menu'; +import SidebarCategoryMenu from '.'; +import * as redux from 'react-redux'; +import CreateNewCategoryMenuItem from './create_new_category_menu_item'; + +const initialState = { + entities: { + preferences: { + myPreferences: {}, + }, + channels: { + channels: {}, + channelsInTeam: {}, + }, + users: { + currentUserId: '', + profiles: {}, + }, + teams: { + currentTeamId: '', + }, + general: { + config: { + ExperimentalGroupUnreadChannels: 'default_off', + }, + }, + }, + views: { + channel: { + lastUnreadChannel: '', + }, + }, +}; + +jest.spyOn(redux, 'useSelector').mockImplementation((cb) => cb(initialState)); +jest.spyOn(redux, 'useDispatch').mockReturnValue((t) => t); describe('components/sidebar/sidebar_category/sidebar_category_menu', () => { const categoryId = 'test_category_id'; @@ -24,9 +58,6 @@ describe('components/sidebar/sidebar_category/sidebar_category_menu', () => { muted: false, collapsed: false, }, - openModal: jest.fn(), - setCategoryMuted: jest.fn(), - setCategorySorting: jest.fn(), }; test('should match snapshot and contain correct buttons', () => { @@ -35,7 +66,7 @@ describe('components/sidebar/sidebar_category/sidebar_category_menu', () => { ); expect(wrapper.find(`#rename-${categoryId}`)).toHaveLength(1); - expect(wrapper.find(`#create-${categoryId}`)).toHaveLength(1); + expect(wrapper.find(CreateNewCategoryMenuItem)).toHaveLength(1); expect(wrapper.find(`#delete-${categoryId}`)).toHaveLength(1); expect(wrapper).toMatchSnapshot(); diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/index.ts b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/index.ts deleted file mode 100644 index 901122c60d..0000000000 --- a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect, ConnectedProps} from 'react-redux'; - -import {setCategoryMuted, setCategorySorting} from 'mattermost-redux/actions/channel_categories'; - -import {openModal} from 'actions/views/modals'; - -import SidebarCategoryMenu from './sidebar_category_menu'; - -const mapDispatchToProps = { - openModal, - setCategoryMuted, - setCategorySorting, -}; - -const connector = connect(null, mapDispatchToProps); - -export type PropsFromRedux = ConnectedProps; - -export default connector(SidebarCategoryMenu); diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/sidebar_category_menu.tsx b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/index.tsx similarity index 59% rename from webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/sidebar_category_menu.tsx rename to webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/index.tsx index 168adbfaba..481a6df3ae 100644 --- a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/sidebar_category_menu.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/index.tsx @@ -1,9 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {memo, useState} from 'react'; +import React, {memo, useCallback} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; -import classNames from 'classnames'; import { BellOutlineIcon, @@ -12,8 +11,6 @@ import { FormatListBulletedIcon, SortAlphabeticalAscendingIcon, ClockOutlineIcon, - FolderPlusOutlineIcon, - DotsVerticalIcon, ChevronRightIcon, } from '@mattermost/compass-icons/components'; @@ -29,32 +26,46 @@ import DeleteCategoryModal from 'components/delete_category_modal'; import EditCategoryModal from 'components/edit_category_modal'; import * as Menu from 'components/menu'; -import type {PropsFromRedux} from './index'; +import SidebarCategoryGenericMenu from './sidebar_category_generic_menu'; +import MarkAsReadMenuItem from './mark_as_read_menu_item'; +import CreateNewCategoryMenuItem from './create_new_category_menu_item'; +import {useDispatch, useSelector} from 'react-redux'; +import {shouldShowUnreadsCategory} from 'mattermost-redux/selectors/entities/preferences'; +import {setCategoryMuted, setCategorySorting} from 'mattermost-redux/actions/channel_categories'; -type OwnProps = { +import {openModal} from 'actions/views/modals'; +import {makeGetUnreadIdsForCategory} from 'selectors/views/channel_sidebar'; +import {GlobalState} from 'types/store'; +import {readMultipleChannels} from 'mattermost-redux/actions/channels'; + +type Props = { category: ChannelCategory; }; -type Props = OwnProps & PropsFromRedux; +const getUnreadsIdsForCategory = makeGetUnreadIdsForCategory(); -const SidebarCategoryMenu = (props: Props) => { - const [isMenuOpen, setIsMenuOpen] = useState(false); +const SidebarCategoryMenu = ({ + category, +}: Props) => { + const dispatch = useDispatch(); + const showUnreadsCategory = useSelector(shouldShowUnreadsCategory); + const unreadsIds = useSelector((state: GlobalState) => getUnreadsIdsForCategory(state, category)); const {formatMessage} = useIntl(); let muteUnmuteCategoryMenuItem: JSX.Element | null = null; - if (props.category.type !== CategoryTypes.DIRECT_MESSAGES) { + if (category.type !== CategoryTypes.DIRECT_MESSAGES) { function toggleCategoryMute() { - props.setCategoryMuted(props.category.id, !props.category.muted); + dispatch(setCategoryMuted(category.id, !category.muted)); } muteUnmuteCategoryMenuItem = ( } labels={ - props.category.muted ? ( + category.muted ? ( { let deleteCategoryMenuItem: JSX.Element | null = null; let renameCategoryMenuItem: JSX.Element | null = null; - if (props.category.type === CategoryTypes.CUSTOM) { + if (category.type === CategoryTypes.CUSTOM) { function handleDeleteCategory() { - props.openModal({ + dispatch(openModal({ modalId: ModalIdentifiers.DELETE_CATEGORY, dialogType: DeleteCategoryModal, dialogProps: { - category: props.category, + category, }, - }); + })); } deleteCategoryMenuItem = ( { ); function handleRenameCategory() { - props.openModal({ + dispatch(openModal({ modalId: ModalIdentifiers.EDIT_CATEGORY, dialogType: EditCategoryModal, dialogProps: { - categoryId: props.category.id, - initialCategoryName: props.category.display_name, + categoryId: category.id, + initialCategoryName: category.display_name, }, - }); + })); } renameCategoryMenuItem = ( } @@ -127,7 +138,7 @@ const SidebarCategoryMenu = (props: Props) => { } function handleSortChannels(sorting: CategorySorting) { - props.setCategorySorting(props.category.id, sorting); + dispatch(setCategorySorting(category.id, sorting)); trackEvent('ui', `ui_sidebar_sort_dm_${sorting}`); } @@ -138,7 +149,7 @@ const SidebarCategoryMenu = (props: Props) => { /> ); let sortChannelsIcon = ; - if (props.category.sorting === CategorySorting.Alphabetical) { + if (category.sorting === CategorySorting.Alphabetical) { sortChannelsSelectedValue = ( { /> ); sortChannelsIcon = ; - } else if (props.category.sorting === CategorySorting.Recency) { + } else if (category.sorting === CategorySorting.Recency) { sortChannelsSelectedValue = ( { const sortChannelsMenuItem = ( { )} - menuId={`sortChannels-${props.category.id}-menu`} + menuId={`sortChannels-${category.id}-menu`} menuAriaLabel={formatMessage({id: 'sidebar_left.sidebar_category_menu.sort.dropdownAriaLabel', defaultMessage: 'Sort submenu'})} > { onClick={() => handleSortChannels(CategorySorting.Alphabetical)} /> { onClick={() => handleSortChannels(CategorySorting.Recency)} /> { ); - function handleCreateCategory() { - props.openModal({ - modalId: ModalIdentifiers.EDIT_CATEGORY, - dialogType: EditCategoryModal, - }); - trackEvent('ui', 'ui_sidebar_category_menu_createCategory'); - } + const handleViewCategory = useCallback(() => { + dispatch(readMultipleChannels(unreadsIds)); + trackEvent('ui', 'ui_sidebar_category_menu_viewCategory'); + }, [dispatch, unreadsIds]); - const createNewCategoryMenuItem = ( - } - labels={( - - )} - /> - ); - - function handleMenuToggle(isOpen: boolean) { - setIsMenuOpen(isOpen); - } + const markAsReadMenuItem = showUnreadsCategory ? + null : + ( + + ); return ( -
- , - }} - menuButtonTooltip={{ - id: `SidebarCategoryMenu-ButtonTooltip-${props.category.id}`, - text: formatMessage({id: 'sidebar_left.sidebar_category_menu.editCategory', defaultMessage: 'Category options'}), - class: 'hidden-xs', - }} - menu={{ - id: `SidebarChannelMenu-MenuList-${props.category.id}`, - 'aria-label': formatMessage({id: 'sidebar_left.sidebar_category_menu.dropdownAriaLabel', defaultMessage: 'Edit category menu'}), - onToggle: handleMenuToggle, - }} - > - {muteUnmuteCategoryMenuItem} - {renameCategoryMenuItem} - {deleteCategoryMenuItem} - - {sortChannelsMenuItem} - - {createNewCategoryMenuItem} - -
+ + {markAsReadMenuItem} + {markAsReadMenuItem && } + {muteUnmuteCategoryMenuItem} + {renameCategoryMenuItem} + {deleteCategoryMenuItem} + + {sortChannelsMenuItem} + + + ); }; diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/mark_as_read_confirm_modal.tsx b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/mark_as_read_confirm_modal.tsx new file mode 100644 index 0000000000..1387ed1bab --- /dev/null +++ b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/mark_as_read_confirm_modal.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; + +import {GenericModal} from '@mattermost/components'; + +import 'components/category_modal.scss'; + +type Props = { + handleConfirm: () => void; + numChannels: number; + onExited: () => void; +}; + +const handleCancel = () => null; + +const MarkAsReadConfirmModal = ({ + handleConfirm, + numChannels, + onExited, +}: Props) => { + const intl = useIntl(); + + const header = intl.formatMessage({id: 'mark_as_read_confirm_modal.header', defaultMessage: 'Mark as read'}); + const body = intl.formatMessage({id: 'mark_as_read_confirm_modal.body', defaultMessage: 'Are you sure you want to mark {numChannels} channels as read?'}, {numChannels}); + const confirm = intl.formatMessage({id: 'mark_as_read_confirm_modal.confirm', defaultMessage: 'Mark as read'}); + + return ( + + + {body} + + + ); +}; + +export default MarkAsReadConfirmModal; diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/mark_as_read_menu_item.tsx b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/mark_as_read_menu_item.tsx new file mode 100644 index 0000000000..dd0474fb22 --- /dev/null +++ b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/mark_as_read_menu_item.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import * as Menu from 'components/menu'; + +import {MarkAsUnreadIcon} from '@mattermost/compass-icons/components'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch} from 'react-redux'; +import {openModal} from 'actions/views/modals'; +import {ModalIdentifiers} from 'utils/constants'; +import MarkAsReadConfirmModal from './mark_as_read_confirm_modal'; + +type Props = ({ + id: string; + handleViewCategory: () => void; + numChannels: number; +}) + +const MarkAsUnreadItem = ({ + id, + handleViewCategory, + numChannels, +}: Props) => { + const dispatch = useDispatch(); + + const onClick = useCallback(() => { + if (numChannels <= 0) { + return; + } + + if (numChannels === 1) { + handleViewCategory(); + return; + } + + dispatch(openModal({ + modalId: ModalIdentifiers.DELETE_CATEGORY, + dialogType: MarkAsReadConfirmModal, + dialogProps: { + handleConfirm: handleViewCategory, + numChannels, + }, + })); + }, [dispatch, handleViewCategory, numChannels]); + + return ( + 1} + leadingElement={} + labels={( + + )} + /> + ); +}; + +export default MarkAsUnreadItem; diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/sidebar_category_generic_menu.tsx b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/sidebar_category_generic_menu.tsx new file mode 100644 index 0000000000..1dc42c724d --- /dev/null +++ b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_menu/sidebar_category_generic_menu.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {memo, useState} from 'react'; +import {useIntl} from 'react-intl'; +import classNames from 'classnames'; + +import { + DotsVerticalIcon, +} from '@mattermost/compass-icons/components'; + +import * as Menu from 'components/menu'; + +type Props = { + id: string; + children: React.ReactNode[]; +}; + +const SidebarCategoryGenericMenu = ({ + id, + children, +}: Props) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const {formatMessage} = useIntl(); + + function handleMenuToggle(isOpen: boolean) { + setIsMenuOpen(isOpen); + } + + return ( +
+ , + }} + menuButtonTooltip={{ + id: `SidebarCategoryMenu-ButtonTooltip-${id}`, + text: formatMessage({id: 'sidebar_left.sidebar_category_menu.editCategory', defaultMessage: 'Category options'}), + class: 'hidden-xs', + }} + menu={{ + id: `SidebarChannelMenu-MenuList-${id}`, + 'aria-label': formatMessage({id: 'sidebar_left.sidebar_category_menu.dropdownAriaLabel', defaultMessage: 'Edit category menu'}), + onToggle: handleMenuToggle, + }} + > + {children} + +
+ ); +}; + +export default memo(SidebarCategoryGenericMenu); diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu/sidebar_category_sorting_menu.test.tsx b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu.test.tsx similarity index 59% rename from webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu/sidebar_category_sorting_menu.test.tsx rename to webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu.test.tsx index dbbfab7a8d..fbc8dcb09c 100644 --- a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu/sidebar_category_sorting_menu.test.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu.test.tsx @@ -2,21 +2,35 @@ // See LICENSE.txt for license information. import React from 'react'; +import * as redux from 'react-redux'; import {shallow} from 'enzyme'; import {TestHelper} from 'utils/test_helper'; -import Constants from 'utils/constants'; import SidebarCategorySortingMenu from './sidebar_category_sorting_menu'; +const initialState = { + entities: { + users: { + currentUserId: 'user_id', + }, + preferences: { + myPreferences: { + 'sidebar_settings--limit_visible_dms_gms': { + value: '10', + }, + }, + }, + }, +}; + +jest.spyOn(redux, 'useSelector').mockImplementation((cb) => cb(initialState)); +jest.spyOn(redux, 'useDispatch').mockReturnValue((t) => t); + describe('components/sidebar/sidebar_category/sidebar_category_sorting_menu', () => { const baseProps = { category: TestHelper.getCategoryMock(), handleOpenDirectMessagesModal: jest.fn(), - selectedDmNumber: Constants.DM_AND_GM_SHOW_COUNTS[0], - currentUserId: TestHelper.getUserMock().id, - setCategorySorting: jest.fn(), - savePreferences: jest.fn(), }; test('should match snapshot', () => { diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu/sidebar_category_sorting_menu.tsx b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu.tsx similarity index 78% rename from webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu/sidebar_category_sorting_menu.tsx rename to webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu.tsx index aa3937dc2b..69cd66829f 100644 --- a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu/sidebar_category_sorting_menu.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu.tsx @@ -24,21 +24,30 @@ import {trackEvent} from 'actions/telemetry_actions'; import * as Menu from 'components/menu'; -import type {PropsFromRedux} from './index'; +import {useDispatch, useSelector} from 'react-redux'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {getVisibleDmGmLimit} from 'mattermost-redux/selectors/entities/preferences'; +import {setCategorySorting} from 'mattermost-redux/actions/channel_categories'; +import {savePreferences} from 'mattermost-redux/actions/preferences'; -type OwnProps = { +type Props = { category: ChannelCategory; handleOpenDirectMessagesModal: (e: MouseEvent | KeyboardEvent) => void; }; -type Props = OwnProps & PropsFromRedux; - -const SidebarCategorySortingMenu = (props: Props) => { +const SidebarCategorySortingMenu = ({ + category, + handleOpenDirectMessagesModal, +}: Props) => { const [isMenuOpen, setIsMenuOpen] = useState(false); const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + const selectedDmNumber = useSelector(getVisibleDmGmLimit); + const currentUserId = useSelector(getCurrentUserId); + function handleSortDirectMessages(sorting: CategorySorting) { - props.setCategorySorting(props.category.id, sorting); + dispatch(setCategorySorting(category.id, sorting)); trackEvent('ui', `ui_sidebar_sort_dm_${sorting}`); } @@ -49,7 +58,7 @@ const SidebarCategorySortingMenu = (props: Props) => { defaultMessage='Recent Activity' /> ); - if (props.category.sorting === CategorySorting.Alphabetical) { + if (category.sorting === CategorySorting.Alphabetical) { sortDirectMessagesSelectedValue = ( { const sortDirectMessagesMenuItem = ( { } - menuId={`sortDirectMessages-${props.category.id}-menu`} + menuId={`sortDirectMessages-${category.id}-menu`} > { onClick={() => handleSortDirectMessages(CategorySorting.Alphabetical)} /> { ); function handlelimitVisibleDMsGMs(number: number) { - props.savePreferences(props.currentUserId, [{ - user_id: props.currentUserId, + dispatch(savePreferences(currentUserId, [{ + user_id: currentUserId, category: Constants.Preferences.CATEGORY_SIDEBAR_SETTINGS, name: Preferences.LIMIT_VISIBLE_DMS_GMS, value: number.toString(), - }]); + }])); } - let showMessagesCountSelectedValue = {props.selectedDmNumber}; - if (props.selectedDmNumber === 10000) { + let showMessagesCountSelectedValue = {selectedDmNumber}; + if (selectedDmNumber === 10000) { showMessagesCountSelectedValue = ( { const showMessagesCountMenuItem = ( } labels={( { )} - menuId={`showMessagesCount-${props.category.id}-menu`} + menuId={`showMessagesCount-${category.id}-menu`} > { {Constants.DM_AND_GM_SHOW_COUNTS.map((dmGmShowCount) => ( {dmGmShowCount}} onClick={() => handlelimitVisibleDMsGMs(dmGmShowCount)} /> @@ -163,8 +172,8 @@ const SidebarCategorySortingMenu = (props: Props) => { const openDirectMessageMenuItem = ( } labels={( { > , }} menuButtonTooltip={{ - id: `SidebarCategorySortingMenu-ButtonTooltip-${props.category.id}`, + id: `SidebarCategorySortingMenu-ButtonTooltip-${category.id}`, text: formatMessage({id: 'sidebar_left.sidebar_category_menu.editCategory', defaultMessage: 'Category options'}), class: 'hidden-xs', }} menu={{ - id: `SidebarCategorySortingMenu-MenuList-${props.category.id}`, + id: `SidebarCategorySortingMenu-MenuList-${category.id}`, 'aria-label': formatMessage({id: 'sidebar_left.sidebar_category_menu.dropdownAriaLabel', defaultMessage: 'Edit category menu'}), onToggle: handleMenuToggle, }} diff --git a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu/index.ts b/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu/index.ts deleted file mode 100644 index 7620caf956..0000000000 --- a/webapp/channels/src/components/sidebar/sidebar_category/sidebar_category_sorting_menu/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect, ConnectedProps} from 'react-redux'; - -import {setCategorySorting} from 'mattermost-redux/actions/channel_categories'; -import {savePreferences} from 'mattermost-redux/actions/preferences'; -import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; -import {getVisibleDmGmLimit} from 'mattermost-redux/selectors/entities/preferences'; - -import {GlobalState} from 'types/store'; - -import SidebarCategorySortingMenu from './sidebar_category_sorting_menu'; - -function mapStateToProps(state: GlobalState) { - return { - selectedDmNumber: getVisibleDmGmLimit(state), - currentUserId: getCurrentUserId(state), - }; -} - -const mapDispatchToProps = { - setCategorySorting, - savePreferences, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); - -export type PropsFromRedux = ConnectedProps; - -export default connector(SidebarCategorySortingMenu); diff --git a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_menu/sidebar_channel_menu.tsx b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_menu/sidebar_channel_menu.tsx index 573a51e3a3..03d04b1dfc 100644 --- a/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_menu/sidebar_channel_menu.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_channel/sidebar_channel_menu/sidebar_channel_menu.tsx @@ -37,7 +37,7 @@ const SidebarChannelMenu = (props: Props) => { let markAsReadUnreadMenuItem: JSX.Element | null = null; if (props.isUnread) { function handleMarkAsRead() { - props.markChannelAsRead(props.channel.id); + props.markChannelAsRead(props.channel.id, true); trackEvent('ui', 'ui_sidebar_channel_menu_markAsRead'); } diff --git a/webapp/channels/src/components/sidebar/sidebar_list/index.ts b/webapp/channels/src/components/sidebar/sidebar_list/index.ts index add88b7bd8..2f3d8b158f 100644 --- a/webapp/channels/src/components/sidebar/sidebar_list/index.ts +++ b/webapp/channels/src/components/sidebar/sidebar_list/index.ts @@ -17,7 +17,6 @@ import { setDraggingState, stopDragging, clearChannelSelection, - multiSelectChannelAdd, } from 'actions/views/channel_sidebar'; import {close, switchToLhsStaticPage} from 'actions/views/lhs'; import { @@ -68,7 +67,6 @@ function mapDispatchToProps(dispatch: Dispatch) { setDraggingState, stopDragging, clearChannelSelection, - multiSelectChannelAdd, switchToLhsStaticPage, }, dispatch), }; diff --git a/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.tsx b/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.tsx index 2524c1ed5e..be4fc3125e 100644 --- a/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.tsx @@ -99,7 +99,6 @@ type Props = { setDraggingState: (data: DraggingState) => void; stopDragging: () => void; clearChannelSelection: () => void; - multiSelectChannelAdd: (channelId: string) => void; }; }; diff --git a/webapp/channels/src/components/sidebar/unread_channels.tsx b/webapp/channels/src/components/sidebar/unread_channels.tsx new file mode 100644 index 0000000000..62f9d73fba --- /dev/null +++ b/webapp/channels/src/components/sidebar/unread_channels.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; + +import {SidebarCategoryHeaderStatic} from './sidebar_category_header'; +import SidebarChannel from './sidebar_channel'; +import SidebarCategoryGenericMenu from './sidebar_category/sidebar_category_menu/sidebar_category_generic_menu'; +import MarkAsReadMenuItem from './sidebar_category/sidebar_category_menu/mark_as_read_menu_item'; +import * as Menu from 'components/menu'; +import CreateNewCategoryMenuItem from './sidebar_category/sidebar_category_menu/create_new_category_menu_item'; +import {trackEvent} from 'actions/telemetry_actions'; +import {useDispatch, useSelector} from 'react-redux'; +import {getUnreadChannels} from 'selectors/views/channel_sidebar'; +import {readMultipleChannels} from 'mattermost-redux/actions/channels'; + +type Props = { + setChannelRef: (channelId: string, ref: HTMLLIElement) => void; +}; + +export default function UnreadChannels({ + setChannelRef, +}: Props) { + const intl = useIntl(); + const unreadChannels = useSelector(getUnreadChannels); + const dispatch = useDispatch(); + + const handleViewCategory = useCallback(() => { + if (!unreadChannels.length) { + return; + } + + dispatch(readMultipleChannels(unreadChannels.map((v) => v.id))); + trackEvent('ui', 'ui_sidebar_category_menu_viewUnreadCategory'); + }, [unreadChannels, dispatch]); + + return ( +
+ + + + + + + +
+
    + {unreadChannels.map((channel, index) => { + return ( + + ); + })} +
+
+
+ ); +} diff --git a/webapp/channels/src/components/sidebar/unread_channels/index.ts b/webapp/channels/src/components/sidebar/unread_channels/index.ts deleted file mode 100644 index 7dc4476640..0000000000 --- a/webapp/channels/src/components/sidebar/unread_channels/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect} from 'react-redux'; - -import {getUnreadChannels} from 'selectors/views/channel_sidebar'; - -import {GlobalState} from 'types/store'; - -import UnreadChannels from './unread_channels'; - -function mapStateToProps(state: GlobalState) { - return { - unreadChannels: getUnreadChannels(state), - }; -} - -export default connect(mapStateToProps)(UnreadChannels); diff --git a/webapp/channels/src/components/sidebar/unread_channels/unread_channels.tsx b/webapp/channels/src/components/sidebar/unread_channels/unread_channels.tsx deleted file mode 100644 index 69cecaff61..0000000000 --- a/webapp/channels/src/components/sidebar/unread_channels/unread_channels.tsx +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {useIntl} from 'react-intl'; - -import {Channel} from '@mattermost/types/channels'; - -import {SidebarCategoryHeaderStatic} from '../sidebar_category_header'; -import SidebarChannel from '../sidebar_channel'; - -type Props = { - setChannelRef: (channelId: string, ref: HTMLLIElement) => void; - unreadChannels: Channel[]; -}; - -export default function UnreadChannels(props: Props) { - const intl = useIntl(); - - if (props.unreadChannels.length === 0) { - return null; - } - - return ( -
- -
-
    - {props.unreadChannels.map((channel, index) => { - return ( - - ); - })} -
-
-
- ); -} diff --git a/webapp/channels/src/components/team_controller/index.ts b/webapp/channels/src/components/team_controller/index.ts index a76a2f9b90..05de8f26a8 100644 --- a/webapp/channels/src/components/team_controller/index.ts +++ b/webapp/channels/src/components/team_controller/index.ts @@ -4,7 +4,7 @@ import {connect, ConnectedProps} from 'react-redux'; import {RouteComponentProps} from 'react-router-dom'; -import {fetchAllMyTeamsChannelsAndChannelMembersREST, fetchMyChannelsAndMembersREST, viewChannel} from 'mattermost-redux/actions/channels'; +import {fetchAllMyTeamsChannelsAndChannelMembersREST, fetchMyChannelsAndMembersREST} from 'mattermost-redux/actions/channels'; import {getLicense, getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId, getMyTeams} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; @@ -39,7 +39,6 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) { const disableRefetchingOnBrowserFocus = config.DisableRefetchingOnBrowserFocus === 'true'; return { - currentUser, currentTeamId: getCurrentTeamId(state), currentChannelId: getCurrentChannelId(state), teamsList: getMyTeams(state), @@ -55,7 +54,6 @@ const mapDispatchToProps = { fetchChannelsAndMembers, fetchMyChannelsAndMembersREST, fetchAllMyTeamsChannelsAndChannelMembersREST, - viewChannel, markChannelAsReadOnFocus, initializeTeam, joinTeam, diff --git a/webapp/channels/src/components/team_controller/team_controller.tsx b/webapp/channels/src/components/team_controller/team_controller.tsx index 60dbc84758..70b7c881cb 100644 --- a/webapp/channels/src/components/team_controller/team_controller.tsx +++ b/webapp/channels/src/components/team_controller/team_controller.tsx @@ -108,10 +108,6 @@ function TeamController(props: Props) { function handleBlur() { window.isActive = false; blurTime.current = Date.now(); - - if (props.currentUser) { - props.viewChannel(''); - } } function handleKeydown(event: KeyboardEvent) { @@ -137,7 +133,7 @@ function TeamController(props: Props) { window.removeEventListener('blur', handleBlur); window.removeEventListener('keydown', handleKeydown); }; - }, [props.selectedThreadId, props.graphQLEnabled, props.currentChannelId, props.currentTeamId, props.currentUser.id]); + }, [props.selectedThreadId, props.graphQLEnabled, props.currentChannelId, props.currentTeamId]); // Effect runs on mount, adds active state to window useEffect(() => { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 017e49ee00..5c293a4f25 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3922,6 +3922,9 @@ "mark_all_threads_as_read_modal.confirm": "Mark all as read", "mark_all_threads_as_read_modal.description": "This will clear the unread state and mention badges on all your threads. Are you sure?", "mark_all_threads_as_read_modal.title": "Mark all your threads as read?", + "mark_as_read_confirm_modal.body": "Are you sure you want to mark {numChannels} channels as read?", + "mark_as_read_confirm_modal.confirm": "Mark as read", + "mark_as_read_confirm_modal.header": "Mark as read", "marketplace_command.disabled": "The marketplace is disabled. Please contact your System Administrator for details.", "marketplace_command.no_permission": "You do not have the appropriate permissions to access the marketplace.", "marketplace_modal_list.no_plugins_filter": "No results for \"{filter}\"", @@ -4798,6 +4801,7 @@ "sidebar_left.sidebar_category_menu.renameCategory": "Rename Category", "sidebar_left.sidebar_category_menu.sort.dropdownAriaLabel": "Sort submenu", "sidebar_left.sidebar_category_menu.unmuteCategory": "Unmute Category", + "sidebar_left.sidebar_category_menu.viewCategory": "Mark category as read", "sidebar_left.sidebar_category.newDropBoxLabel": "Drag channels here...", "sidebar_left.sidebar_category.newLabel": "new", "sidebar_left.sidebar_channel_menu.addMembers": "Add Members", 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 335df65453..0bb38e2158 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 @@ -637,364 +637,6 @@ describe('Actions.Channels', () => { } }); - describe('viewChannel', () => { - test('should contact server and update last_viewed_at of both channels', async () => { - const channelId = TestHelper.generateId(); - const prevChannelId = TestHelper.generateId(); - - const currentUserId = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - myMembers: { - [channelId]: { - channel_id: channelId, - last_viewed_at: 1000, - roles: '', - }, - [prevChannelId]: { - channel_id: prevChannelId, - last_viewed_at: 1000, - roles: '', - }, - }, - }, - users: { - currentUserId, - }, - }, - }); - - nock(Client4.getBaseRoute()). - post('/channels/members/me/view', {channel_id: channelId, prev_channel_id: prevChannelId, collapsed_threads_supported: true}). - reply(200, OK_RESPONSE); - - const now = Date.now(); - - const result = await store.dispatch(Actions.viewChannel(channelId, prevChannelId)); - expect(result).toEqual({data: true}); - - const state = store.getState(); - expect(state.entities.channels.myMembers[channelId].last_viewed_at).toBeGreaterThan(now); - expect(state.entities.channels.myMembers[prevChannelId].last_viewed_at).toBeGreaterThan(now); - }); - - test('should clear manually unread state from current channel', async () => { - const channelId = TestHelper.generateId(); - - const currentUserId = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - manuallyUnread: { - [channelId]: true, - }, - myMembers: { - [channelId]: { - channel_id: channelId, - last_viewed_at: 1000, - roles: '', - }, - }, - }, - users: { - currentUserId, - }, - }, - }); - - nock(Client4.getBaseRoute()). - post('/channels/members/me/view', {channel_id: channelId, prev_channel_id: '', collapsed_threads_supported: true}). - reply(200, OK_RESPONSE); - - const result = await store.dispatch(Actions.viewChannel(channelId)); - expect(result).toEqual({data: true}); - - const state = store.getState(); - expect(state.entities.channels.manuallyUnread[channelId]).not.toBe(true); - }); - - test('should not update last_viewed_at of previous channel if it is manually marked as unread', async () => { - const channelId = TestHelper.generateId(); - const prevChannelId = TestHelper.generateId(); - - const currentUserId = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - manuallyUnread: { - [prevChannelId]: true, - }, - myMembers: { - [channelId]: { - channel_id: channelId, - last_viewed_at: 1000, - roles: '', - }, - [prevChannelId]: { - channel_id: prevChannelId, - last_viewed_at: 1000, - roles: '', - }, - }, - }, - users: { - currentUserId, - }, - }, - }); - - nock(Client4.getBaseRoute()). - post('/channels/members/me/view', {channel_id: channelId, prev_channel_id: '', collapsed_threads_supported: true}). - reply(200, OK_RESPONSE); - - const now = Date.now(); - - const result = await store.dispatch(Actions.viewChannel(channelId, prevChannelId)); - expect(result).toEqual({data: true}); - - const state = store.getState(); - expect(state.entities.channels.myMembers[channelId].last_viewed_at).toBeGreaterThan(now); - expect(state.entities.channels.myMembers[prevChannelId].last_viewed_at).toBe(1000); - }); - }); - - it('markChannelAsViewed', async () => { - nock(Client4.getBaseRoute()). - post('/channels'). - reply(201, TestHelper.fakeChannelWithId(TestHelper.basicTeam!.id)); - - const userChannel = await Client4.createChannel( - TestHelper.fakeChannel(TestHelper.basicTeam!.id), - ); - - nock(Client4.getBaseRoute()). - get(`/users/me/teams/${TestHelper.basicTeam!.id}/channels`). - query(true). - reply(200, [userChannel, TestHelper.basicChannel]); - - nock(Client4.getBaseRoute()). - get(`/users/me/teams/${TestHelper.basicTeam!.id}/channels/members`). - reply(200, [{user_id: TestHelper.basicUser!.id, roles: 'channel_user', channel_id: userChannel.id}, TestHelper.basicChannelMember]); - - await store.dispatch(Actions.fetchMyChannelsAndMembersREST(TestHelper.basicTeam!.id)); - - const timestamp = Date.now(); - let members = store.getState().entities.channels.myMembers; - let member = members[TestHelper.basicChannel!.id]; - const otherMember = members[userChannel.id]; - expect(member).toBeTruthy(); - expect(otherMember).toBeTruthy(); - - await TestHelper.wait(50); - - await store.dispatch(Actions.markChannelAsViewed(TestHelper.basicChannel!.id)); - - members = store.getState().entities.channels.myMembers; - member = members[TestHelper.basicChannel!.id]; - expect(member.last_viewed_at > timestamp).toBeTruthy(); - }); - - describe('markChannelAsUnread', () => { - it('plain message', () => { - const teamId = TestHelper.generateId(); - const channelId = TestHelper.generateId(); - const userId = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - channels: { - [channelId]: {team_id: teamId}, - }, - messageCounts: { - [channelId]: {total: 10}, - }, - myMembers: { - [channelId]: {msg_count: 10, mention_count: 0}, - }, - }, - teams: { - myMembers: { - [teamId]: {msg_count: 0, mention_count: 0}, - }, - }, - users: { - currentUserId: userId, - }, - }, - }); - - store.dispatch(Actions.markChannelAsUnread(teamId, channelId, [TestHelper.generateId()], false)); - - const state = store.getState(); - expect(state.entities.channels.messageCounts[channelId].total).toEqual(11); - expect(state.entities.channels.myMembers[channelId].msg_count).toEqual(10); - expect(state.entities.channels.myMembers[channelId].mention_count).toEqual(0); - expect(state.entities.teams.myMembers[teamId].msg_count).toEqual(1); - expect(state.entities.teams.myMembers[teamId].mention_count).toEqual(0); - }); - - it('message mentioning current user', () => { - const teamId = TestHelper.generateId(); - const channelId = TestHelper.generateId(); - const userId = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - channels: { - [channelId]: {team_id: teamId}, - }, - messageCounts: { - [channelId]: {total: 10}, - }, - myMembers: { - [channelId]: {msg_count: 10, mention_count: 0}, - }, - }, - teams: { - myMembers: { - [teamId]: {msg_count: 0, mention_count: 0}, - }, - }, - users: { - currentUserId: userId, - }, - }, - }); - - store.dispatch(Actions.markChannelAsUnread(teamId, channelId, [userId], false)); - - const state = store.getState(); - expect(state.entities.channels.messageCounts[channelId].total).toEqual(11); - expect(state.entities.channels.myMembers[channelId].msg_count).toEqual(10); - expect(state.entities.channels.myMembers[channelId].mention_count).toEqual(1); - expect(state.entities.teams.myMembers[teamId].msg_count).toEqual(1); - expect(state.entities.teams.myMembers[teamId].mention_count).toEqual(1); - }); - - it('plain message with mark_unread="mention"', () => { - const teamId = TestHelper.generateId(); - const channelId = TestHelper.generateId(); - const userId = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - channels: { - [channelId]: {team_id: teamId}, - }, - messageCounts: { - [channelId]: {total: 10}, - }, - myMembers: { - [channelId]: {msg_count: 10, mention_count: 0, notify_props: {mark_unread: MarkUnread.MENTION}}, - }, - }, - teams: { - myMembers: { - [teamId]: {msg_count: 0, mention_count: 0}, - }, - }, - users: { - currentUserId: userId, - }, - }, - }); - - store.dispatch(Actions.markChannelAsUnread(teamId, channelId, [TestHelper.generateId()], false)); - - const state = store.getState(); - expect(state.entities.channels.messageCounts[channelId].total).toEqual(11); - expect(state.entities.channels.myMembers[channelId].msg_count).toEqual(11); - expect(state.entities.channels.myMembers[channelId].mention_count).toEqual(0); - expect(state.entities.teams.myMembers[teamId].msg_count).toEqual(0); - expect(state.entities.teams.myMembers[teamId].mention_count).toEqual(0); - }); - - it('message mentioning current user with mark_unread="mention"', () => { - const teamId = TestHelper.generateId(); - const channelId = TestHelper.generateId(); - const userId = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - channels: { - [channelId]: {team_id: teamId}, - }, - messageCounts: { - [channelId]: {total: 10}, - }, - myMembers: { - [channelId]: {msg_count: 10, mention_count: 0, notify_props: {mark_unread: MarkUnread.MENTION}}, - }, - }, - teams: { - myMembers: { - [teamId]: {msg_count: 0, mention_count: 0}, - }, - }, - users: { - currentUserId: userId, - }, - }, - }); - - store.dispatch(Actions.markChannelAsUnread(teamId, channelId, [userId], false)); - - const state = store.getState(); - expect(state.entities.channels.messageCounts[channelId].total).toEqual(11); - expect(state.entities.channels.myMembers[channelId].msg_count).toEqual(11); - expect(state.entities.channels.myMembers[channelId].mention_count).toEqual(1); - expect(state.entities.teams.myMembers[teamId].msg_count).toEqual(0); - expect(state.entities.teams.myMembers[teamId].mention_count).toEqual(1); - }); - - it('channel member should not be updated if it has already been fetched', () => { - const teamId = TestHelper.generateId(); - const channelId = TestHelper.generateId(); - const userId = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - channels: { - [channelId]: {team_id: teamId}, - }, - messageCounts: { - [channelId]: {total: 8}, - }, - myMembers: { - [channelId]: {msg_count: 5, mention_count: 2}, - }, - }, - teams: { - myMembers: { - [teamId]: {msg_count: 2, mention_count: 1}, - }, - }, - users: { - currentUserId: userId, - }, - }, - }); - - store.dispatch(Actions.markChannelAsUnread(teamId, channelId, [userId], true)); - - const state = store.getState(); - expect(state.entities.channels.messageCounts[channelId].total).toEqual(8); - expect(state.entities.channels.myMembers[channelId].msg_count).toEqual(5); - expect(state.entities.channels.myMembers[channelId].mention_count).toEqual(2); - expect(state.entities.teams.myMembers[teamId].msg_count).toEqual(3); - expect(state.entities.teams.myMembers[teamId].mention_count).toEqual(2); - }); - }); - describe('markChannelAsRead', () => { it('one read channel', async () => { const channelId = TestHelper.generateId(); @@ -1199,136 +841,6 @@ describe('Actions.Channels', () => { expect(state.entities.teams.myMembers[teamId].msg_count).toBe(3); }); - it('two unread channels, same team, reading both', async () => { - const channelId1 = TestHelper.generateId(); - const channelId2 = TestHelper.generateId(); - const teamId = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - channels: { - [channelId1]: { - id: channelId1, - team_id: teamId, - }, - [channelId2]: { - id: channelId2, - team_id: teamId, - }, - }, - messageCounts: { - [channelId1]: {total: 10}, - [channelId2]: {total: 12}, - }, - myMembers: { - [channelId1]: { - channel_id: channelId1, - mention_count: 2, - msg_count: 5, - last_viewed_at: 1000, - }, - [channelId2]: { - channel_id: channelId2, - mention_count: 4, - msg_count: 9, - last_viewed_at: 2000, - }, - }, - }, - teams: { - myMembers: { - [teamId]: { - id: teamId, - mention_count: 6, - msg_count: 8, - }, - }, - }, - }, - }); - - await store.dispatch(Actions.markChannelAsRead(channelId1, channelId2)); - - const state = store.getState(); - - expect(state.entities.channels.myMembers[channelId1].mention_count).toBe(0); - expect(state.entities.channels.myMembers[channelId1].msg_count).toBe(state.entities.channels.messageCounts[channelId1].total); - expect(state.entities.channels.myMembers[channelId1].last_viewed_at).toBeGreaterThan(1000); - - expect(state.entities.channels.myMembers[channelId2].mention_count).toBe(0); - expect(state.entities.channels.myMembers[channelId2].msg_count).toBe(state.entities.channels.messageCounts[channelId2].total); - expect(state.entities.channels.myMembers[channelId2].last_viewed_at).toBeGreaterThan(2000); - - expect(state.entities.teams.myMembers[teamId].mention_count).toBe(0); - expect(state.entities.teams.myMembers[teamId].msg_count).toBe(0); - }); - - it('two unread channels, same team, reading both (opposite order)', async () => { - const channelId1 = TestHelper.generateId(); - const channelId2 = TestHelper.generateId(); - const teamId = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - channels: { - [channelId1]: { - id: channelId1, - team_id: teamId, - }, - [channelId2]: { - id: channelId2, - team_id: teamId, - }, - }, - messageCounts: { - [channelId1]: {total: 10}, - [channelId2]: {total: 12}, - }, - myMembers: { - [channelId1]: { - channel_id: channelId1, - mention_count: 2, - msg_count: 5, - last_viewed_at: 1000, - }, - [channelId2]: { - channel_id: channelId2, - mention_count: 4, - msg_count: 9, - last_viewed_at: 1000, - }, - }, - }, - teams: { - myMembers: { - [teamId]: { - id: teamId, - mention_count: 6, - msg_count: 8, - }, - }, - }, - }, - }); - - await store.dispatch(Actions.markChannelAsRead(channelId2, channelId1)); - - const state = store.getState(); - - expect(state.entities.channels.myMembers[channelId1].mention_count).toBe(0); - expect(state.entities.channels.myMembers[channelId1].msg_count).toBe(state.entities.channels.messageCounts[channelId1].total); - expect(state.entities.channels.myMembers[channelId1].last_viewed_at).toBeGreaterThan(1000); - - expect(state.entities.channels.myMembers[channelId2].mention_count).toBe(0); - expect(state.entities.channels.myMembers[channelId2].msg_count).toBe(state.entities.channels.messageCounts[channelId2].total); - expect(state.entities.channels.myMembers[channelId2].last_viewed_at).toBeGreaterThan(2000); - - expect(state.entities.teams.myMembers[teamId].mention_count).toBe(0); - expect(state.entities.teams.myMembers[teamId].msg_count).toBe(0); - }); - it('two unread channels, different teams, reading one', async () => { const channelId1 = TestHelper.generateId(); const channelId2 = TestHelper.generateId(); @@ -1402,154 +914,6 @@ describe('Actions.Channels', () => { expect(state.entities.teams.myMembers[teamId2].mention_count).toBe(4); expect(state.entities.teams.myMembers[teamId2].msg_count).toBe(3); }); - - it('two unread channels, different teams, reading both', async () => { - const channelId1 = TestHelper.generateId(); - const channelId2 = TestHelper.generateId(); - const teamId1 = TestHelper.generateId(); - const teamId2 = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - channels: { - [channelId1]: { - id: channelId1, - team_id: teamId1, - }, - [channelId2]: { - id: channelId2, - team_id: teamId2, - }, - }, - messageCounts: { - [channelId1]: {total: 10}, - [channelId2]: {total: 12}, - }, - myMembers: { - [channelId1]: { - channel_id: channelId1, - mention_count: 2, - msg_count: 5, - last_viewed_at: 1000, - }, - [channelId2]: { - channel_id: channelId2, - mention_count: 4, - msg_count: 9, - last_viewed_at: 2000, - }, - }, - }, - teams: { - myMembers: { - [teamId1]: { - id: teamId1, - mention_count: 2, - msg_count: 5, - }, - [teamId2]: { - id: teamId2, - mention_count: 4, - msg_count: 3, - }, - }, - }, - }, - }); - - await store.dispatch(Actions.markChannelAsRead(channelId1, channelId2)); - - const state = store.getState(); - - expect(state.entities.channels.myMembers[channelId1].mention_count).toBe(0); - expect(state.entities.channels.myMembers[channelId1].msg_count).toBe(state.entities.channels.messageCounts[channelId1].total); - expect(state.entities.channels.myMembers[channelId1].last_viewed_at).toBeGreaterThan(1000); - - expect(state.entities.channels.myMembers[channelId2].mention_count).toBe(0); - expect(state.entities.channels.myMembers[channelId2].msg_count).toBe(state.entities.channels.messageCounts[channelId2].total); - expect(state.entities.channels.myMembers[channelId2].last_viewed_at).toBeGreaterThan(2000); - - expect(state.entities.teams.myMembers[teamId1].mention_count).toBe(0); - expect(state.entities.teams.myMembers[teamId1].msg_count).toBe(0); - - expect(state.entities.teams.myMembers[teamId2].mention_count).toBe(0); - expect(state.entities.teams.myMembers[teamId2].msg_count).toBe(0); - }); - - it('two unread channels, different teams, reading both (opposite order)', async () => { - const channelId1 = TestHelper.generateId(); - const channelId2 = TestHelper.generateId(); - const teamId1 = TestHelper.generateId(); - const teamId2 = TestHelper.generateId(); - - store = configureStore({ - entities: { - channels: { - channels: { - [channelId1]: { - id: channelId1, - team_id: teamId1, - }, - [channelId2]: { - id: channelId2, - team_id: teamId2, - }, - }, - messageCounts: { - [channelId1]: {total: 10}, - [channelId2]: {total: 12}, - }, - myMembers: { - [channelId1]: { - channel_id: channelId1, - mention_count: 2, - msg_count: 5, - last_viewed_at: 1000, - }, - [channelId2]: { - channel_id: channelId2, - mention_count: 4, - msg_count: 9, - last_viewed_at: 2000, - }, - }, - }, - teams: { - myMembers: { - [teamId1]: { - id: teamId1, - mention_count: 2, - msg_count: 5, - }, - [teamId2]: { - id: teamId2, - mention_count: 4, - msg_count: 3, - }, - }, - }, - }, - }); - - await store.dispatch(Actions.markChannelAsRead(channelId1, channelId2)); - - const state = store.getState(); - - expect(state.entities.channels.myMembers[channelId1].mention_count).toBe(0); - expect(state.entities.channels.myMembers[channelId1].msg_count).toBe(state.entities.channels.messageCounts[channelId1].total); - expect(state.entities.channels.myMembers[channelId1].last_viewed_at).toBeGreaterThan(1000); - - expect(state.entities.channels.myMembers[channelId2].mention_count).toBe(0); - expect(state.entities.channels.myMembers[channelId2].msg_count).toBe(state.entities.channels.messageCounts[channelId2].total); - expect(state.entities.channels.myMembers[channelId2].last_viewed_at).toBeGreaterThan(2000); - - expect(state.entities.teams.myMembers[teamId1].mention_count).toBe(0); - expect(state.entities.teams.myMembers[teamId1].msg_count).toBe(0); - - expect(state.entities.teams.myMembers[teamId2].mention_count).toBe(0); - expect(state.entities.teams.myMembers[teamId2].msg_count).toBe(0); - }); }); it('getChannels', async () => { 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 ca93ebe4d5..ecac6430fa 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -728,102 +728,39 @@ export function unarchiveChannel(channelId: string): ActionFunc { }; } -export function viewChannel(channelId: string, prevChannelId = ''): ActionFunc { +export function updateApproximateViewTime(channelId: string): ActionFunc { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const {currentUserId} = getState().entities.users; const {myPreferences} = getState().entities.preferences; + const viewTimePref = myPreferences[`${Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME}--${channelId}`]; const viewTime = viewTimePref ? parseInt(viewTimePref.value!, 10) : 0; - const prevChanManuallyUnread = isManuallyUnread(getState(), prevChannelId); - if (viewTime < new Date().getTime() - (3 * 60 * 60 * 1000)) { const preferences = [ {user_id: currentUserId, category: Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME, name: channelId, value: new Date().getTime().toString()}, ]; savePreferences(currentUserId, preferences)(dispatch); } - - try { - await Client4.viewMyChannel(channelId, prevChanManuallyUnread ? '' : prevChannelId); - } catch (error) { - forceLogoutIfNecessary(error, dispatch, getState); - dispatch(logError(error)); - - return {error}; - } - - const actions: AnyAction[] = []; - - const {myMembers} = getState().entities.channels; - const member = myMembers[channelId]; - if (member) { - if (isManuallyUnread(getState(), channelId)) { - actions.push({ - type: ChannelTypes.REMOVE_MANUALLY_UNREAD, - data: {channelId}, - }); - } - actions.push({ - type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, - data: {...member, last_viewed_at: new Date().getTime()}, - }); - dispatch(loadRolesIfNeeded(member.roles.split(' '))); - } - - const prevMember = myMembers[prevChannelId]; - if (!prevChanManuallyUnread && prevMember) { - actions.push({ - type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, - data: {...prevMember, last_viewed_at: new Date().getTime()}, - }); - dispatch(loadRolesIfNeeded(prevMember.roles.split(' '))); - } - - dispatch(batchActions(actions)); - return {data: true}; }; } -export function markChannelAsViewed(channelId: string, prevChannelId?: string): ActionFunc { - return (dispatch: DispatchFunc, getState: GetStateFunc) => { - const actions = actionsToMarkChannelAsViewed(getState, channelId, prevChannelId); - - return dispatch(batchActions(actions)); - }; -} - -export function actionsToMarkChannelAsViewed(getState: GetStateFunc, channelId: string, prevChannelId = '') { - const actions: AnyAction[] = []; - - const state = getState(); - const {myMembers} = state.entities.channels; - - const member = myMembers[channelId]; - if (member) { - actions.push({ - type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, - data: {...member, last_viewed_at: Date.now()}, - }); - - if (isManuallyUnread(state, channelId)) { - actions.push({ - type: ChannelTypes.REMOVE_MANUALLY_UNREAD, - data: {channelId}, - }); +export function readMultipleChannels(channelIds: string[]): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + let response; + try { + response = await Client4.readMultipleChannels(channelIds); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; } - } - const prevMember = myMembers[prevChannelId]; - if (prevMember && !isManuallyUnread(state, prevChannelId)) { - actions.push({ - type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBER, - data: {...prevMember, last_viewed_at: Date.now()}, - }); - } + dispatch(markMultipleChannelsAsRead(response.last_viewed_at_times)); - return actions; + return {data: true}; + }; } export function getChannels(teamId: string, page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE): ActionFunc { @@ -1217,38 +1154,27 @@ export function updateChannelPurpose(channelId: string, purpose: string): Action }; } -export function markChannelAsRead(channelId: string, prevChannelId?: string, updateLastViewedAt = true): ActionFunc { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const prevChanManuallyUnread = isManuallyUnread(state, prevChannelId); +export function markChannelAsRead(channelId: string, skipUpdateViewTime = false): ActionFunc { + return (dispatch: DispatchFunc, getState: GetStateFunc) => { + if (skipUpdateViewTime) { + dispatch(updateApproximateViewTime(channelId)); + } + dispatch(markChannelAsViewedOnServer(channelId)); - const actions = actionsToMarkChannelAsRead(getState, channelId, prevChannelId); + const actions = actionsToMarkChannelAsRead(getState, channelId); + if (actions.length > 0) { + dispatch(batchActions(actions)); + } - // Send channel last viewed at to the server - if (updateLastViewedAt) { - dispatch(markChannelAsViewedOnServer(channelId, prevChanManuallyUnread ? '' : prevChannelId)); + return {data: true}; + }; +} - const now = Date.now(); - - // Don't use actionsToMarkChannelAsViewed here because that overwrites fields modified by - // actionsToMarkChannelAsRead - actions.push({ - type: ChannelTypes.RECEIVED_LAST_VIEWED_AT, - data: { - channel_id: channelId, - last_viewed_at: now, - }, - }); - - if (prevChannelId && !prevChanManuallyUnread) { - actions.push({ - type: ChannelTypes.RECEIVED_LAST_VIEWED_AT, - data: { - channel_id: prevChannelId, - last_viewed_at: now, - }, - }); - } +export function markMultipleChannelsAsRead(channelTimes: Record): ActionFunc { + return (dispatch: DispatchFunc, getState: GetStateFunc) => { + const actions: AnyAction[] = []; + for (const id of Object.keys(channelTimes)) { + actions.push(...actionsToMarkChannelAsRead(getState, id, channelTimes[id])); } if (actions.length > 0) { @@ -1259,33 +1185,41 @@ export function markChannelAsRead(channelId: string, prevChannelId?: string, upd }; } -export function markChannelAsViewedOnServer(channelId: string, prevChannelId?: string): ActionFunc { - return (dispatch: DispatchFunc, getState: GetStateFunc) => { - Client4.viewMyChannel(channelId, prevChannelId).then().catch((error) => { +export function markChannelAsViewedOnServer(channelId: string): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + try { + await Client4.viewMyChannel(channelId); + } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); return {error}; - }); + } + + // const actions: AnyAction[] = []; + // for (const id of Object.keys(response.last_viewed_at_times)) { + // actions.push({ + // type: ChannelTypes.RECEIVED_LAST_VIEWED_AT, + // data: { + // channel_id: channelId, + // last_viewed_at: response.last_viewed_at_times[id], + // }, + // }); + // } return {data: true}; }; } -export function actionsToMarkChannelAsRead(getState: GetStateFunc, channelId: string, prevChannelId?: string) { +export function actionsToMarkChannelAsRead(getState: GetStateFunc, channelId: string, viewedAt = Date.now()) { const state = getState(); const {channels, messageCounts, myMembers} = state.entities.channels; - const prevChanManuallyUnread = isManuallyUnread(state, prevChannelId); - // Update channel member objects to set all mentions and posts as viewed const channel = channels[channelId]; const messageCount = messageCounts[channelId]; - const prevChannel = (!prevChanManuallyUnread && prevChannelId) ? channels[prevChannelId] : null; // May be null since prevChannelId is optional - const prevMessageCount = (!prevChanManuallyUnread && prevChannelId) ? messageCounts[prevChannelId] : null; // Update team member objects to set mentions and posts in channel as viewed const channelMember = myMembers[channelId]; - const prevChannelMember = (!prevChanManuallyUnread && prevChannelId) ? myMembers[prevChannelId] : null; // May also be null const actions: AnyAction[] = []; @@ -1310,6 +1244,14 @@ export function actionsToMarkChannelAsRead(getState: GetStateFunc, channelId: st amountUrgent: channelMember.urgent_mention_count, }, }); + + actions.push({ + type: ChannelTypes.RECEIVED_LAST_VIEWED_AT, + data: { + channel_id: channelId, + last_viewed_at: viewedAt, + }, + }); } if (channel && isManuallyUnread(getState(), channelId)) { @@ -1319,82 +1261,9 @@ export function actionsToMarkChannelAsRead(getState: GetStateFunc, channelId: st }); } - if (prevChannel && prevChannelMember && prevMessageCount) { - actions.push({ - type: ChannelTypes.DECREMENT_UNREAD_MSG_COUNT, - data: { - teamId: prevChannel.team_id, - channelId: prevChannelId, - amount: prevMessageCount.total - prevChannelMember.msg_count, - amountRoot: prevMessageCount.root - prevChannelMember.msg_count_root, - }, - }); - - actions.push({ - type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT, - data: { - teamId: prevChannel.team_id, - channelId: prevChannelId, - amount: prevChannelMember.mention_count, - amountRoot: prevChannelMember.mention_count_root, - amountUrgent: prevChannelMember.urgent_mention_count, - }, - }); - } - return actions; } -// Increments the number of posts in the channel by 1 and marks it as unread if necessary -export function markChannelAsUnread(teamId: string, channelId: string, mentions: string[], fetchedChannelMember = false, isRoot = false): ActionFunc { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - const state = getState(); - const {myMembers} = state.entities.channels; - const {currentUserId} = state.entities.users; - - const actions: AnyAction[] = [{ - type: ChannelTypes.INCREMENT_UNREAD_MSG_COUNT, - data: { - teamId, - channelId, - amount: 1, - amountRoot: isRoot ? 1 : 0, - onlyMentions: myMembers[channelId] && myMembers[channelId].notify_props && - myMembers[channelId].notify_props.mark_unread === MarkUnread.MENTION, - fetchedChannelMember, - }, - }]; - - if (!fetchedChannelMember) { - actions.push({ - type: ChannelTypes.INCREMENT_TOTAL_MSG_COUNT, - data: { - channelId, - amountRoot: isRoot ? 1 : 0, - amount: 1, - }, - }); - } - - if (mentions && mentions.indexOf(currentUserId) !== -1) { - actions.push({ - type: ChannelTypes.INCREMENT_UNREAD_MENTION_COUNT, - data: { - teamId, - channelId, - amountRoot: isRoot ? 1 : 0, - amount: 1, - fetchedChannelMember, - }, - }); - } - - dispatch(batchActions(actions)); - - return {data: true}; - }; -} - export function actionsToMarkChannelAsUnread(getState: GetStateFunc, teamId: string, channelId: string, mentions: string[], fetchedChannelMember = false, isRoot = false, priority = '') { const state = getState(); const {myMembers} = state.entities.channels; @@ -1609,8 +1478,6 @@ export default { joinChannel, deleteChannel, unarchiveChannel, - viewChannel, - markChannelAsViewed, getChannels, autocompleteChannels, autocompleteChannelsForSearch, @@ -1622,7 +1489,6 @@ export default { updateChannelHeader, updateChannelPurpose, markChannelAsRead, - markChannelAsUnread, favoriteChannel, unfavoriteChannel, membersMinusGroupMembers, diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/websocket.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/websocket.ts index 7f9a84438d..82a881c20d 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/websocket.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/websocket.ts @@ -11,7 +11,7 @@ const WebsocketEvents = { CHANNEL_DELETED: 'channel_deleted', CHANNEL_UNARCHIVED: 'channel_restored', CHANNEL_UPDATED: 'channel_updated', - CHANNEL_VIEWED: 'channel_viewed', + MULTIPLE_CHANNELS_VIEWED: 'multiple_channels_viewed', CHANNEL_MEMBER_UPDATED: 'channel_member_updated', CHANNEL_SCHEME_UPDATED: 'channel_scheme_updated', DIRECT_ADDED: 'direct_added', diff --git a/webapp/channels/src/selectors/views/channel_sidebar.ts b/webapp/channels/src/selectors/views/channel_sidebar.ts index 4ef55a444f..c464115450 100644 --- a/webapp/channels/src/selectors/views/channel_sidebar.ts +++ b/webapp/channels/src/selectors/views/channel_sidebar.ts @@ -208,6 +208,33 @@ export function makeGetFilteredChannelIdsForCategory(): (state: GlobalState, cat ); } +// Returns a selector that, given a category, returns the ids of channels visible in that category. The returned channels do not +// include unread channels when the Unreads category is enabled. +export function makeGetUnreadIdsForCategory(): (state: GlobalState, category: ChannelCategory) => string[] { + const getChannelIdsForCategory = makeGetChannelIdsForCategory(); + const emptyList: string[] = []; + + return createSelector( + 'makeGetFilteredChannelIdsForCategory', + getChannelIdsForCategory, + getUnreadChannelIdsSet, + shouldShowUnreadsCategory, + (channelIds, unreadChannelIdsSet, showUnreadsCategory) => { + if (showUnreadsCategory) { + return emptyList; + } + + const filtered = channelIds.filter((id) => unreadChannelIdsSet.has(id)); + + if (filtered.length === 0) { + return emptyList; + } + + return filtered.length === channelIds.length ? channelIds : filtered; + }, + ); +} + export function getDraggingState(state: GlobalState): DraggingState { return state.views.channelSidebar.draggingState; } diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index fb19606eca..dc8edee451 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -570,7 +570,7 @@ export const SocketEvents = { CHANNEL_DELETED: 'channel_deleted', CHANNEL_UNARCHIVED: 'channel_restored', CHANNEL_UPDATED: 'channel_updated', - CHANNEL_VIEWED: 'channel_viewed', + MULTIPLE_CHANNELS_VIEWED: 'multiple_channels_viewed', CHANNEL_MEMBER_UPDATED: 'channel_member_updated', CHANNEL_SCHEME_UPDATED: 'channel_scheme_updated', DIRECT_ADDED: 'direct_added', diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 0a5a1b4652..d42cb0deb1 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -1775,14 +1775,21 @@ export default class Client4 { ); }; - viewMyChannel = (channelId: string, prevChannelId?: string) => { - const data = {channel_id: channelId, prev_channel_id: prevChannelId, collapsed_threads_supported: true}; + viewMyChannel = (channelId: string) => { + const data = {channel_id: channelId, collapsed_threads_supported: true}; return this.doFetch( `${this.getChannelsRoute()}/members/me/view`, {method: 'post', body: JSON.stringify(data)}, ); }; + readMultipleChannels = (channelIds: string[]) => { + return this.doFetch( + `${this.getChannelsRoute()}/members/me/mark_read`, + {method: 'post', body: JSON.stringify(channelIds)}, + ); + }; + autocompleteChannels = (teamId: string, name: string) => { return this.doFetch( `${this.getTeamRoute(teamId)}/channels/autocomplete${buildQueryString({name})}`,