diff --git a/api/v4/source/channels.yaml b/api/v4/source/channels.yaml index 9566147659a..bcf74c0fdb1 100644 --- a/api/v4/source/channels.yaml +++ b/api/v4/source/channels.yaml @@ -2120,6 +2120,52 @@ $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + "/api/v4/channels/members/{user_id}/direct/read": + put: + tags: + - channels + summary: Mark all direct and group messages as read + description: | + Mark all direct and group messages as read for a user. + + ##### Permissions + + Must be logged in as user or have `edit_other_users` permission. + + __Minimum server version__: 11.3 + operationId: MarkAllDirectMessagesRead + parameters: + - in: path + name: user_id + description: User ID to mark messages as read for + required: true + schema: + type: string + responses: + "200": + description: Direct messages marked as read successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + description: Value should be "OK" if successful + last_viewed_at_times: + type: object + description: A JSON object mapping channel IDs to the last viewed times + additionalProperties: + type: integer + format: int64 + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "501": + $ref: "#/components/responses/NotImplemented" "/api/v4/users/{user_id}/teams/{team_id}/channels/members": get: tags: diff --git a/api/v4/source/users.yaml b/api/v4/source/users.yaml index 572d834c6fd..c22441ec34d 100644 --- a/api/v4/source/users.yaml +++ b/api/v4/source/users.yaml @@ -3158,6 +3158,60 @@ $ref: "#/components/responses/Unauthorized" "404": $ref: "#/components/responses/NotFound" + "/api/v4/users/{user_id}/teams/{team_id}/read": + put: + tags: + - channels + summary: Mark all channels and threads in a team as read + description: | + Mark all channels and threads in a team as read for a user. + + ##### Permissions + + Must be logged in as user or have `edit_other_users` permission. Must have `view_team` permission for the team. + + __Minimum server version__: 11.3 + operationId: MarkAllTeamChannelsRead + parameters: + - name: user_id + in: path + description: User ID to mark channels as read for + required: true + schema: + type: string + - name: team_id + in: path + description: Team ID to mark all channels as read in + required: true + schema: + type: string + responses: + "200": + description: Team channels marked as read successfully + content: + application/json: + schema: + type: object + properties: + status: + type: string + description: Value should be "OK" if successful + last_viewed_at_times: + type: object + description: A JSON object mapping channel IDs to the last viewed times + additionalProperties: + type: integer + format: int64 + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "501": + $ref: "#/components/responses/NotImplemented" "/api/v4/users/{user_id}/teams/{team_id}/threads/read": put: tags: diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index d55b316a81d..426cbec08fc 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -26,6 +26,7 @@ func (api *API) InitChannel() { api.BaseRoutes.Channels.Handle("/group", api.APISessionRequired(createGroupChannel)).Methods(http.MethodPost) api.BaseRoutes.Channels.Handle("/members/{user_id:[A-Za-z0-9]+}/view", api.APISessionRequired(viewChannel)).Methods(http.MethodPost) api.BaseRoutes.Channels.Handle("/members/{user_id:[A-Za-z0-9]+}/mark_read", api.APISessionRequired(readMultipleChannels)).Methods(http.MethodPost) + api.BaseRoutes.Channels.Handle("/members/{user_id:[A-Za-z0-9]+}/direct/read", api.APISessionRequired(readAllMessages)).Methods(http.MethodPut) api.BaseRoutes.Channels.Handle("/{channel_id:[A-Za-z0-9]+}/scheme", api.APISessionRequired(updateChannelScheme)).Methods(http.MethodPut) api.BaseRoutes.Channels.Handle("/stats/member_count", api.APISessionRequired(getChannelsMemberCount)).Methods(http.MethodPost) @@ -37,6 +38,7 @@ func (api *API) InitChannel() { api.BaseRoutes.ChannelsForTeam.Handle("/search", api.APISessionRequiredDisableWhenBusy(searchChannelsForTeam)).Methods(http.MethodPost) api.BaseRoutes.ChannelsForTeam.Handle("/autocomplete", api.APISessionRequired(autocompleteChannelsForTeam)).Methods(http.MethodGet) api.BaseRoutes.ChannelsForTeam.Handle("/search_autocomplete", api.APISessionRequired(autocompleteChannelsForTeamForSearch)).Methods(http.MethodGet) + api.BaseRoutes.User.Handle("/teams/{team_id:[A-Za-z0-9]+}/read", api.APISessionRequired(readAllInTeam)).Methods(http.MethodPut) if api.srv.Config().FeatureFlags.ManagedChannelCategories { api.BaseRoutes.ChannelsForTeam.Handle("/managed_categories", api.APISessionRequired(getManagedCategories)).Methods(http.MethodGet) } @@ -606,6 +608,44 @@ func createDirectChannel(c *Context, w http.ResponseWriter, r *http.Request) { } } +func readAllMessages(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.EnableShiftEscapeToMarkAllRead { + c.Err = model.NewAppError("readAllMessages", "api.mark_all_as_read.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + c.RequireUserId() + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventMarkMessagesRead, model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId) + + if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + c.SetPermissionError(model.PermissionEditOtherUsers) + return + } + + times, err := c.App.MarkAllDirectAndGroupMessagesViewed(c.AppContext, c.Params.UserId, c.AppContext.Session().Id, c.App.IsCRTEnabledForUser(c.AppContext, c.Params.UserId)) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + + 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 searchGroupChannels(c *Context, w http.ResponseWriter, r *http.Request) { var props *model.ChannelSearch err := json.NewDecoder(r.Body).Decode(&props) @@ -1866,6 +1906,49 @@ func readMultipleChannels(c *Context, w http.ResponseWriter, r *http.Request) { } } +func readAllInTeam(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.Config().FeatureFlags.EnableShiftEscapeToMarkAllRead { + c.Err = model.NewAppError("readAllInTeam", "api.mark_all_as_read.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } + + c.RequireUserId().RequireTeamId() + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventMarkTeamRead, model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "user_id", c.Params.UserId) + model.AddEventParameterToAuditRec(auditRec, "team_id", c.Params.TeamId) + + if !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { + c.SetPermissionError(model.PermissionEditOtherUsers) + return + } + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } + + times, err := c.App.MarkTeamChannelsAndThreadsViewed(c.AppContext, c.Params.TeamId, c.Params.UserId, c.AppContext.Session().Id, c.App.IsCRTEnabledForUser(c.AppContext, c.Params.UserId)) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + + 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 4a8db044e2a..d762340a23a 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -4506,6 +4506,244 @@ func TestReadMultipleChannels(t *testing.T) { }) } +func TestReadAllMessages(t *testing.T) { + mainHelper.Parallel(t) + th := SetupConfig(t, func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = true + }).InitBasic(t) + client := th.Client + user := th.BasicUser + + t.Run("Should fail when feature flag is disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = false + }) + defer th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = true + }) + + _, resp, err := client.ReadAllMessages(context.Background(), user.Id) + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + }) + + t.Run("Should successfully mark all direct messages as read for self", func(t *testing.T) { + dmChannel, _, err := client.CreateDirectChannel(context.Background(), user.Id, th.BasicUser2.Id) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: dmChannel.Id, + Message: "test message", + }) + require.NoError(t, err) + + channelResponse, _, err := client.ReadAllMessages(context.Background(), user.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + }) + + t.Run("Should successfully mark all group messages as read for self", func(t *testing.T) { + gmChannel, _, err := client.CreateGroupChannel(context.Background(), []string{user.Id, th.BasicUser2.Id, th.TeamAdminUser.Id}) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: gmChannel.Id, + Message: "test group message", + }) + require.NoError(t, err) + + channelResponse, _, err := client.ReadAllMessages(context.Background(), user.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + }) + + t.Run("Should fail marking messages for other user without permission", func(t *testing.T) { + _, _, err := client.ReadAllMessages(context.Background(), th.BasicUser2.Id) + require.Error(t, err) + }) + + t.Run("Admin should succeed in marking messages for other user", func(t *testing.T) { + adminClient := th.SystemAdminClient + + dmChannel, _, err := adminClient.CreateDirectChannel(context.Background(), th.BasicUser2.Id, th.TeamAdminUser.Id) + require.NoError(t, err) + + _, _, err = adminClient.CreatePost(context.Background(), &model.Post{ + ChannelId: dmChannel.Id, + Message: "test message for user2", + }) + require.NoError(t, err) + + channelResponse, _, err := adminClient.ReadAllMessages(context.Background(), th.BasicUser2.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + }) + + t.Run("Should handle empty direct/group message list gracefully", func(t *testing.T) { + channelResponse, _, err := client.ReadAllMessages(context.Background(), user.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + }) + + t.Run("Should fail with invalid user ID", func(t *testing.T) { + _, _, err := client.ReadAllMessages(context.Background(), "invalid-user-id") + require.Error(t, err) + }) +} + +func TestReadAllInTeam(t *testing.T) { + mainHelper.Parallel(t) + th := SetupConfig(t, func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = true + }).InitBasic(t) + client := th.Client + user := th.BasicUser + team := th.BasicTeam + + t.Run("Should fail when feature flag is disabled", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = false + }) + defer th.App.UpdateConfig(func(cfg *model.Config) { + cfg.FeatureFlags.EnableShiftEscapeToMarkAllRead = true + }) + + _, resp, err := client.ReadAllInTeam(context.Background(), user.Id, team.Id) + + require.Error(t, err) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + }) + + t.Run("Should successfully mark all channels and threads as read for self in team", func(t *testing.T) { + channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id) + require.NoError(t, err) + channel2, _, err := client.GetChannel(context.Background(), th.BasicChannel2.Id) + require.NoError(t, err) + + post, _, err := client.CreatePost(context.Background(), &model.Post{ + ChannelId: channel.Id, + Message: "test message in channel 1", + }) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: channel2.Id, + Message: "test message in channel 2", + }) + require.NoError(t, err) + + channelResponse, _, err := client.ReadAllInTeam(context.Background(), user.Id, team.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + require.Contains(t, channelResponse.LastViewedAtTimes, channel.Id) + require.GreaterOrEqual(t, channelResponse.LastViewedAtTimes[channel.Id], post.CreateAt, + "channel last_viewed_at should be at or after the latest post in the channel") + }) + + t.Run("Should fail marking channels for other user without permission", func(t *testing.T) { + _, _, err := client.ReadAllInTeam(context.Background(), th.BasicUser2.Id, team.Id) + require.Error(t, err) + }) + + t.Run("Should fail with invalid team ID", func(t *testing.T) { + _, _, err := client.ReadAllInTeam(context.Background(), user.Id, "invalid-team-id") + require.Error(t, err) + }) + + t.Run("Admin should succeed in marking channels for other user in team", func(t *testing.T) { + adminClient := th.SystemAdminClient + channel, _, err := adminClient.GetChannel(context.Background(), th.BasicChannel.Id) + require.NoError(t, err) + + _, _, err = adminClient.CreatePost(context.Background(), &model.Post{ + ChannelId: channel.Id, + Message: "test message for user2", + }) + require.NoError(t, err) + + channelResponse, _, err := adminClient.ReadAllInTeam(context.Background(), th.BasicUser2.Id, team.Id) + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + }) + + t.Run("Should handle empty channel list gracefully", func(t *testing.T) { + newTeam := th.CreateTeam(t) + th.LinkUserToTeam(t, user, newTeam) + + channelResponse, _, err := client.ReadAllInTeam(context.Background(), user.Id, newTeam.Id) + + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.NotEmpty(t, channelResponse.LastViewedAtTimes, "should have viewed at times") + }) + + t.Run("Should only mark channels in the specified team", func(t *testing.T) { + team2 := th.CreateTeam(t) + th.LinkUserToTeam(t, user, team2) + + channelTeam1, _, err := client.CreateChannel(context.Background(), &model.Channel{ + TeamId: team.Id, + Name: model.NewId(), + DisplayName: "Team 1 Channel", + Type: model.ChannelTypeOpen, + }) + require.NoError(t, err) + + channelTeam2, _, err := client.CreateChannel(context.Background(), &model.Channel{ + TeamId: team2.Id, + Name: model.NewId(), + DisplayName: "Team 2 Channel", + Type: model.ChannelTypeOpen, + }) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: channelTeam1.Id, + Message: "message in team 1", + }) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: channelTeam2.Id, + Message: "message in team 2", + }) + require.NoError(t, err) + + channelResponse, _, err := client.ReadAllInTeam(context.Background(), user.Id, team.Id) + + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.Contains(t, channelResponse.LastViewedAtTimes, channelTeam1.Id, "team1 channel should be marked as read") + require.NotContains(t, channelResponse.LastViewedAtTimes, channelTeam2.Id, "team2 channel should not be marked as read") + }) + + t.Run("Should handle both public and private channels in team", func(t *testing.T) { + _, _, err := client.CreatePost(context.Background(), &model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "public message", + }) + require.NoError(t, err) + + _, _, err = client.CreatePost(context.Background(), &model.Post{ + ChannelId: th.BasicPrivateChannel.Id, + Message: "private message", + }) + require.NoError(t, err) + + channelResponse, _, err := client.ReadAllInTeam(context.Background(), user.Id, team.Id) + + require.NoError(t, err) + require.Equal(t, "OK", channelResponse.Status, "invalid status return") + require.Contains(t, channelResponse.LastViewedAtTimes, th.BasicChannel.Id, "public channel should be marked as read") + require.Contains(t, channelResponse.LastViewedAtTimes, th.BasicPrivateChannel.Id, "private channel should be marked as read") + }) +} + func TestGetChannelUnread(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index ab02d364872..bd9b3b2b21e 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -3319,7 +3319,130 @@ func (a *App) SearchChannelsUserNotIn(rctx request.CTX, teamID string, userID st return channelList, nil } -func (a *App) MarkChannelsAsViewed(rctx request.CTX, channelIDs []string, userID string, currentSessionId string, collapsedThreadsSupported, isCRTEnabled bool) (map[string]int64, *model.AppError) { +func (a *App) MarkTeamChannelsAndThreadsViewed(rctx request.CTX, teamID string, userID string, currentSessionID string, isCRTEnabled bool) (map[string]int64, *model.AppError) { + user, err := a.Srv().Store().User().Get(rctx.Context(), userID) + if err != nil { + return nil, model.NewAppError("MarkTeamChannelsAndThreadsViewed", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + channelsToView, channelsToClearPushNotifications, times, err := a.Srv().Store().Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, user.NotifyProps) + if err != nil { + return nil, model.NewAppError("MarkTeamChannelsAndThreadsViewed", "app.channel.get_channels_by_team_with_unreads_and_with_mentions.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // times already contains every channel the user belongs to in this team, including + // fully-read ones. We pass the full set to the thread store because a CRT-enabled + // user can have unread thread replies in a channel whose channel-level counters are + // already up to date (thread replies don't bump TotalMsgCount). The thread store's + // `LastReplyAt > LastViewed` clause keeps the actual UPDATE bounded to genuinely + // stale thread memberships. + allChannelIDs := make([]string, 0, len(times)) + for channelID := range times { + allChannelIDs = append(allChannelIDs, channelID) + } + if err = a.Srv().Store().Thread().MarkAllAsReadByChannels(userID, allChannelIDs); err != nil { + return nil, model.NewAppError("MarkTeamChannelsAndThreadsViewed", "app.thread.mark_all_as_read_by_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + if len(channelsToView) > 0 { + _, err = a.Srv().Store().Channel().UpdateLastViewedAt(channelsToView, userID) + if err != nil { + var invErr *store.ErrInvalidInput + switch { + case errors.As(err, &invErr): + return nil, model.NewAppError("MarkTeamChannelsAndThreadsViewed", "app.channel.update_last_viewed_at.app_error", nil, "", http.StatusBadRequest).Wrap(err) + default: + return nil, model.NewAppError("MarkTeamChannelsAndThreadsViewed", "app.channel.update_last_viewed_at.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + if *a.Config().ServiceSettings.EnableChannelViewedMessages { + 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 isCRTEnabled { + // Threads can have been marked read across the entire team, so broadcast a + // single team-scoped event. The client routes this to a single + // ALL_TEAM_THREADS_READ Redux action — it does NOT trigger any API calls. + message := model.NewWebSocketEvent(model.WebsocketEventThreadReadChanged, teamID, "", userID, nil, "") + message.Add("timestamp", model.GetMillis()) + a.Publish(message) + } + + return times, nil +} + +func (a *App) MarkAllDirectAndGroupMessagesViewed(rctx request.CTX, userID string, currentSessionID string, isCRTEnabled bool) (map[string]int64, *model.AppError) { + user, err := a.Srv().Store().User().Get(rctx.Context(), userID) + if err != nil { + return nil, model.NewAppError("MarkAllDirectAndGroupMessagesViewed", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + messagesToView, messagesToClearPushNotifications, times, err := a.Srv().Store().Channel().GetDirectMessagesWithUnreadAndMentions(rctx, userID, user.NotifyProps) + if err != nil { + return nil, model.NewAppError("MarkAllDirectAndGroupMessagesViewed", "app.channel.get_channels_by_team_with_unreads_and_with_mentions.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // times already contains every DM/GM the user belongs to, including fully-read + // ones. We pass the full set to the thread store because a CRT-enabled user can + // have unread thread replies in a channel whose channel-level counters are + // already up to date (thread replies don't bump TotalMsgCount). The thread + // store's `LastReplyAt > LastViewed` clause keeps the actual UPDATE bounded to + // genuinely stale thread memberships. + allChannelIDs := make([]string, 0, len(times)) + for channelID := range times { + allChannelIDs = append(allChannelIDs, channelID) + } + if err = a.Srv().Store().Thread().MarkAllAsReadByChannels(userID, allChannelIDs); err != nil { + return nil, model.NewAppError("MarkAllDirectAndGroupMessagesViewed", "app.thread.mark_all_as_read_by_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + if len(messagesToView) > 0 { + _, err = a.Srv().Store().Channel().UpdateLastViewedAt(messagesToView, userID) + if err != nil { + var invErr *store.ErrInvalidInput + switch { + case errors.As(err, &invErr): + return nil, model.NewAppError("MarkAllDirectAndGroupMessagesViewed", "app.channel.update_last_viewed_at.app_error", nil, "", http.StatusBadRequest).Wrap(err) + default: + return nil, model.NewAppError("MarkAllDirectAndGroupMessagesViewed", "app.channel.update_last_viewed_at.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + } + + if *a.Config().ServiceSettings.EnableChannelViewedMessages { + message := model.NewWebSocketEvent(model.WebsocketEventMultipleChannelsViewed, "", "", userID, nil, "") + message.Add("channel_times", times) + a.Publish(message) + } + } + + for _, channelID := range messagesToClearPushNotifications { + a.clearPushNotification(currentSessionID, userID, channelID, "") + } + + if isCRTEnabled { + // Threads can have been marked read in any DM/GM. There's no team to + // broadcast on, so emit one event per channel. The client routes each to a + // single ALL_THREADS_IN_CHANNEL_READ Redux action — no API calls are made. + timestamp := model.GetMillis() + for _, channelID := range allChannelIDs { + message := model.NewWebSocketEvent(model.WebsocketEventThreadReadChanged, "", channelID, userID, nil, "") + message.Add("timestamp", timestamp) + a.Publish(message) + } + } + + return times, nil +} + +func (a *App) MarkChannelsAsViewed(rctx request.CTX, channelIDs []string, userID string, currentSessionID string, collapsedThreadsSupported, isCRTEnabled bool) (map[string]int64, *model.AppError) { var err error user, err := a.Srv().Store().User().Get(rctx.Context(), userID) @@ -3363,7 +3486,7 @@ func (a *App) MarkChannelsAsViewed(rctx request.CTX, channelIDs []string, userID } for _, channelID := range channelsToClearPushNotifications { - a.clearPushNotification(currentSessionId, userID, channelID, "") + a.clearPushNotification(currentSessionID, userID, channelID, "") } if updateThreads && isCRTEnabled { diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index 5e61988f288..6c5bb51bff9 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -2115,6 +2115,138 @@ func (s SqlChannelStore) GetChannelsWithUnreadsAndWithMentions(_ request.CTX, ch return channelsWithUnreads, channelsWithMentions, readTimes, nil } +func (s SqlChannelStore) GetTeamChannelsWithUnreadAndMentions(rctx request.CTX, teamID 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"). + Join("Channels ON ChannelMembers.ChannelId = Channels.Id"). + Where(sq.Eq{ + "Channels.TeamId": teamID, + "ChannelMembers.UserId": userID, + }) + + var channels []struct { + Id string + Type string + TotalMsgCount int + LastPostAt int64 + MsgCount int + MentionCount int + NotifyProps model.StringMap + LastViewedAt int64 + } + + if err := s.GetReplica().SelectBuilder(&channels, query); err != nil { + return nil, nil, nil, errors.Wrap(err, "failed to find team channels with unreads and mentions data") + } + + channelsWithUnreads := make([]string, 0, len(channels)) + channelsWithMentions := make([]string, 0, len(channels)) + readTimes := make(map[string]int64, len(channels)) + + for _, channel := range channels { + 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) + } + } + + readTimes[channel.Id] = max(channel.LastPostAt, channel.LastViewedAt) + } + + return channelsWithUnreads, channelsWithMentions, readTimes, nil +} + +func (s SqlChannelStore) GetDirectMessagesWithUnreadAndMentions(rctx request.CTX, 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"). + Join("Channels ON ChannelMembers.ChannelId = Channels.Id"). + Where(sq.Eq{ + "ChannelMembers.UserId": userID, + "Type": []model.ChannelType{model.ChannelTypeDirect, model.ChannelTypeGroup}, + }) + + var channels []struct { + Id string + Type string + TotalMsgCount int + LastPostAt int64 + MsgCount int + MentionCount int + NotifyProps model.StringMap + LastViewedAt int64 + } + + if err := s.GetReplica().SelectBuilder(&channels, query); err != nil { + return nil, nil, nil, errors.Wrap(err, "failed to find direct or group channels with unreads and mentions data") + } + + channelsWithUnreads := make([]string, 0, len(channels)) + channelsWithMentions := make([]string, 0, len(channels)) + readTimes := make(map[string]int64, len(channels)) + + for _, channel := range channels { + 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) + } + } + + readTimes[channel.Id] = max(channel.LastPostAt, channel.LastViewedAt) + } + + return channelsWithUnreads, channelsWithMentions, readTimes, nil +} + func (s SqlChannelStore) GetMember(rctx request.CTX, channelID string, userID string) (*model.ChannelMember, error) { selectSQL, args, err := s.channelMembersForTeamWithSchemeSelectQuery. Where(sq.Eq{ diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 8a5bed79cc3..97181ddcd96 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -286,6 +286,8 @@ type ChannelStore interface { GetMembersInfoByChannelIds(channelIDs []string) (map[string][]*model.User, error) GetChannelUnread(channelID, userID string) (*model.ChannelUnread, error) GetChannelsWithUnreadsAndWithMentions(rctx request.CTX, channelIDs []string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) + GetTeamChannelsWithUnreadAndMentions(rctx request.CTX, teamID string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) + GetDirectMessagesWithUnreadAndMentions(rctx request.CTX, 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 7c21dfc6eac..447f7aedabd 100644 --- a/server/channels/store/storetest/channel_store.go +++ b/server/channels/store/storetest/channel_store.go @@ -163,6 +163,8 @@ func TestChannelStore(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore t.Run("SetShared", func(t *testing.T) { testSetShared(t, rctx, ss) }) t.Run("GetTeamForChannel", func(t *testing.T) { testGetTeamForChannel(t, rctx, ss) }) t.Run("GetChannelsWithUnreadsAndWithMentions", func(t *testing.T) { testGetChannelsWithUnreadsAndWithMentions(t, rctx, ss) }) + t.Run("GetDirectMessagesWithUnreadAndMentions", func(t *testing.T) { testGetDirectMessagesWithUnreadAndMentions(t, rctx, ss) }) + t.Run("GetTeamChannelsWithUnreadAndMentions", func(t *testing.T) { testGetTeamChannelsWithUnreadAndMentions(t, rctx, ss) }) } func testChannelStoreSave(t *testing.T, rctx request.CTX, ss store.Store) { @@ -8849,3 +8851,516 @@ func testGetChannelsWithUnreadsAndWithMentions(t *testing.T, rctx request.CTX, s require.Len(t, times, 0) }) } + +func testGetDirectMessagesWithUnreadAndMentions(t *testing.T, rctx request.CTX, ss store.Store) { + setupMembership := func( + pushProp string, + withUnreads bool, + withMentions bool, + channelType model.ChannelType, + userID string, + ) (model.Channel, model.ChannelMember) { + var o1 *model.Channel + var err error + + if channelType == model.ChannelTypeDirect { + o1, err = ss.Channel().CreateDirectChannel(rctx, &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) + } else if channelType == model.ChannelTypeGroup { + // No builtin method to create groups, looks + // like a decent amount of logic goes into it too. + o1 = &model.Channel{} + o1.DisplayName = "GroupChannel1" + o1.Name = NewTestID() + o1.Type = model.ChannelTypeGroup + o1.TotalMsgCount = 25 + o1.LastPostAt = 12345 + o1.LastRootPostAt = 12345 + _, err = ss.Channel().Save(rctx, o1, -1) + require.NoError(t, err) + + 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(rctx, &m1) + require.NoError(t, err) + } + + m1, err := ss.Channel().GetMember(rctx, o1.Id, userID) + require.NoError(t, err) + + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + m1.NotifyProps[model.PushNotifyProp] = pushProp + + if !withUnreads { + m1.MsgCount = o1.TotalMsgCount + m1.LastViewedAt = o1.LastPostAt + } + if withMentions { + m1.MentionCount = 5 + } + + m1, err = ss.Channel().UpdateMember(rctx, m1) + require.NoError(t, err) + + return *o1, *m1 + } + + type TestCase struct { + name string + pushProp string + userNotifyProp string + channelType model.ChannelType + 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} + channelTypes := []model.ChannelType{model.ChannelTypeDirect, model.ChannelTypeGroup} + boolRange := []bool{true, false} + + nameTemplate := "pushProp: %s, userPushProp: %s, type: %s, unreads: %t, mentions: %t" + for _, pushProp := range channelNotifyProps { + for _, userNotifyProp := range userNotifyProps { + for _, channelType := range channelTypes { + for _, withUnreads := range boolRange { + ttcc = append(ttcc, TestCase{ + name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, channelType, withUnreads, false), + pushProp: pushProp, + userNotifyProp: userNotifyProp, + channelType: channelType, + withUnreads: withUnreads, + withMentions: false, + }) + if withUnreads { + ttcc = append(ttcc, TestCase{ + name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, channelType, withUnreads, true), + pushProp: pushProp, + userNotifyProp: userNotifyProp, + channelType: channelType, + withUnreads: withUnreads, + withMentions: true, + }) + } + } + } + } + } + + for _, tc := range ttcc { + t.Run(tc.name, func(t *testing.T) { + userID := model.NewId() + o1, m1 := setupMembership(tc.pushProp, tc.withUnreads, tc.withMentions, tc.channelType, userID) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = tc.userNotifyProp + + unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, 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 + // Direct messages seem to always have notify on, at least + // that is the logic copied from GetChannelsWithUnreadsAndMentions + if (tc.channelType == model.ChannelTypeDirect && tc.withUnreads) || + (propToUse == model.UserNotifyAll && tc.withUnreads) || + (propToUse == model.UserNotifyMention && tc.withMentions) { + expectedMentionsLength = 1 + } + + require.Len(t, mentions, expectedMentionsLength) + + if tc.withUnreads { + require.Contains(t, times, o1.Id) + require.Equal(t, o1.LastPostAt, times[o1.Id]) + } + }) + } + + t.Run("multiple directs and groups", func(t *testing.T) { + userID := model.NewId() + dm1, _ := setupMembership(model.ChannelNotifyDefault, true, true, model.ChannelTypeDirect, userID) + dm2, _ := setupMembership(model.ChannelNotifyDefault, true, false, model.ChannelTypeDirect, userID) + gm1, _ := setupMembership(model.ChannelNotifyDefault, true, true, model.ChannelTypeGroup, userID) + gm2, _ := setupMembership(model.ChannelNotifyMention, true, false, model.ChannelTypeGroup, userID) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, userID, userNotifyProps) + require.NoError(t, err) + + require.Len(t, unreads, 4) + require.Contains(t, unreads, dm1.Id) + require.Contains(t, unreads, dm2.Id) + require.Contains(t, unreads, gm1.Id) + require.Contains(t, unreads, gm2.Id) + + require.Len(t, mentions, 3) + // Same as above, direct messages seem to always have notify on + // but group messages need to have notification policies set. + require.Contains(t, mentions, dm1.Id) + require.Contains(t, mentions, dm2.Id) + require.Contains(t, mentions, gm1.Id) + require.NotContains(t, mentions, gm2.Id) + + require.Equal(t, dm1.LastPostAt, times[dm1.Id]) + require.Equal(t, dm2.LastPostAt, times[dm2.Id]) + require.Equal(t, gm1.LastPostAt, times[gm1.Id]) + require.Equal(t, gm2.LastPostAt, times[gm2.Id]) + }) + + t.Run("excludes regular channels", func(t *testing.T) { + userID := model.NewId() + + dm, _ := setupMembership(model.ChannelNotifyDefault, true, true, model.ChannelTypeDirect, userID) + + regularChannel := model.Channel{} + regularChannel.TeamId = model.NewId() + regularChannel.DisplayName = "Regular Channel" + regularChannel.Name = NewTestID() + regularChannel.Type = model.ChannelTypeOpen + regularChannel.TotalMsgCount = 25 + regularChannel.LastPostAt = 12345 + regularChannel.LastRootPostAt = 12345 + _, nErr := ss.Channel().Save(rctx, ®ularChannel, -1) + require.NoError(t, nErr) + + regularMember := model.ChannelMember{} + regularMember.ChannelId = regularChannel.Id + regularMember.UserId = userID + regularMember.NotifyProps = model.GetDefaultChannelNotifyProps() + regularMember.MentionCount = 5 + _, err := ss.Channel().SaveMember(rctx, ®ularMember) + require.NoError(t, err) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, userID, userNotifyProps) + require.NoError(t, err) + + // Should only find the DMs and GMs + require.Len(t, unreads, 1) + require.Contains(t, unreads, dm.Id) + require.NotContains(t, unreads, regularChannel.Id) + + require.Len(t, mentions, 1) + require.Contains(t, mentions, dm.Id) + + require.Equal(t, dm.LastPostAt, times[dm.Id]) + require.NotContains(t, times, regularChannel.Id) + }) + + t.Run("user with no DMs or GMs", func(t *testing.T) { + userID := model.NewId() + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetDirectMessagesWithUnreadAndMentions(rctx, userID, userNotifyProps) + require.NoError(t, err) + + require.Len(t, unreads, 0) + require.Len(t, mentions, 0) + require.Len(t, times, 0) + }) +} + +func testGetTeamChannelsWithUnreadAndMentions(t *testing.T, rctx request.CTX, ss store.Store) { + setupMembership := func( + teamID string, + pushProp string, + withUnreads bool, + withMentions bool, + userID string, + ) (model.Channel, model.ChannelMember) { + o1 := model.Channel{} + o1.TeamId = teamID + o1.DisplayName = "Channel1" + o1.Name = NewTestID() + o1.Type = model.ChannelTypeOpen + o1.TotalMsgCount = 25 + o1.LastPostAt = 12345 + o1.LastRootPostAt = 12345 + _, nErr := ss.Channel().Save(rctx, &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(rctx, &m1) + require.NoError(t, err) + + return o1, m1 + } + + type TestCase struct { + name string + pushProp string + userNotifyProp string + 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, unreads: %t, mentions: %t" + for _, pushProp := range channelNotifyProps { + for _, userNotifyProp := range userNotifyProps { + for _, withUnreads := range boolRange { + ttcc = append(ttcc, TestCase{ + name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, withUnreads, false), + pushProp: pushProp, + userNotifyProp: userNotifyProp, + withUnreads: withUnreads, + withMentions: false, + }) + if withUnreads { + ttcc = append(ttcc, TestCase{ + name: fmt.Sprintf(nameTemplate, pushProp, userNotifyProp, withUnreads, true), + pushProp: pushProp, + userNotifyProp: userNotifyProp, + withUnreads: withUnreads, + withMentions: true, + }) + } + } + } + } + + for _, tc := range ttcc { + t.Run(tc.name, func(t *testing.T) { + teamID := model.NewId() + userID := model.NewId() + o1, m1 := setupMembership(teamID, tc.pushProp, tc.withUnreads, tc.withMentions, userID) + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = tc.userNotifyProp + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, 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 (propToUse == model.UserNotifyAll && tc.withUnreads) || (propToUse == model.UserNotifyMention && tc.withMentions) { + expectedMentionsLength = 1 + } + + require.Len(t, mentions, expectedMentionsLength) + + if tc.withUnreads { + require.Contains(t, times, o1.Id) + require.Equal(t, o1.LastPostAt, times[o1.Id]) + } + }) + } + + t.Run("multiple channels on same team", func(t *testing.T) { + teamID := model.NewId() + userID := model.NewId() + o1, _ := setupMembership(teamID, model.ChannelNotifyDefault, true, true, userID) + o2, _ := setupMembership(teamID, model.ChannelNotifyDefault, true, true, userID) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, 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("excludes channels from other teams", func(t *testing.T) { + teamID1 := model.NewId() + teamID2 := model.NewId() + userID := model.NewId() + + o1, _ := setupMembership(teamID1, model.ChannelNotifyDefault, true, true, userID) + o2, _ := setupMembership(teamID2, model.ChannelNotifyDefault, true, true, userID) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID1, userID, userNotifyProps) + require.NoError(t, err) + + // Should only include channel from teamID1 + require.Len(t, unreads, 1) + require.Contains(t, unreads, o1.Id) + require.NotContains(t, unreads, o2.Id) + + require.Len(t, mentions, 1) + require.Contains(t, mentions, o1.Id) + require.NotContains(t, mentions, o2.Id) + + require.Equal(t, o1.LastPostAt, times[o1.Id]) + require.NotContains(t, times, o2.Id) + }) + + t.Run("non existing team", func(t *testing.T) { + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, "nonexistent-team", "foo", userNotifyProps) + require.NoError(t, err) + + require.Len(t, unreads, 0) + require.Len(t, mentions, 0) + require.Len(t, times, 0) + }) + + t.Run("user not member of any team channels", func(t *testing.T) { + teamID := model.NewId() + userID := model.NewId() + + // Create a channel on the team but don't add the user as a member + o1 := model.Channel{} + o1.TeamId = teamID + o1.DisplayName = "Channel1" + o1.Name = NewTestID() + o1.Type = model.ChannelTypeOpen + o1.TotalMsgCount = 25 + o1.LastPostAt = 12345 + o1.LastRootPostAt = 12345 + _, nErr := ss.Channel().Save(rctx, &o1, -1) + require.NoError(t, nErr) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention + + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps) + require.NoError(t, err) + + require.Len(t, unreads, 0) + require.Len(t, mentions, 0) + require.Len(t, times, 0) + }) + + t.Run("LastViewedAt affects readTimes", func(t *testing.T) { + teamID := model.NewId() + userID := model.NewId() + + o1 := model.Channel{} + o1.TeamId = teamID + o1.DisplayName = "Channel1" + o1.Name = NewTestID() + o1.Type = model.ChannelTypeOpen + o1.TotalMsgCount = 25 + o1.LastPostAt = 10000 + o1.LastRootPostAt = 10000 + _, nErr := ss.Channel().Save(rctx, &o1, -1) + require.NoError(t, nErr) + + m1 := model.ChannelMember{} + m1.ChannelId = o1.Id + m1.UserId = userID + m1.NotifyProps = model.GetDefaultChannelNotifyProps() + // Set LastViewedAt to be AFTER LastPostAt (user viewed after last message) + m1.MsgCount = o1.TotalMsgCount - 5 // Still has unreads + m1.LastViewedAt = 15000 // Newer than LastPostAt + _, err := ss.Channel().SaveMember(rctx, &m1) + require.NoError(t, err) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyAll + + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps) + require.NoError(t, err) + + require.Len(t, unreads, 1) + require.Len(t, mentions, 1) + + // Should return the max of LastPostAt and LastViewedAt + require.Equal(t, int64(15000), times[o1.Id]) + }) + + t.Run("mixed notification settings on same team", func(t *testing.T) { + teamID := model.NewId() + userID := model.NewId() + + // Channel with UserNotifyAll behavior + o1, _ := setupMembership(teamID, model.ChannelNotifyAll, true, false, userID) + + // Channel with UserNotifyMention behavior (has unreads but no mentions) + o2, _ := setupMembership(teamID, model.ChannelNotifyMention, true, false, userID) + + // Channel with UserNotifyMention behavior (has unreads AND mentions) + o3, _ := setupMembership(teamID, model.ChannelNotifyMention, true, true, userID) + + // Channel with UserNotifyNone behavior + o4, _ := setupMembership(teamID, model.ChannelNotifyNone, true, true, userID) + + userNotifyProps := model.GetDefaultChannelNotifyProps() + userNotifyProps[model.PushNotifyProp] = model.UserNotifyMention // User default (not used when channel overrides) + + unreads, mentions, times, err := ss.Channel().GetTeamChannelsWithUnreadAndMentions(rctx, teamID, userID, userNotifyProps) + require.NoError(t, err) + + // All 4 channels should have unreads + require.Len(t, unreads, 4) + require.Contains(t, unreads, o1.Id) + require.Contains(t, unreads, o2.Id) + require.Contains(t, unreads, o3.Id) + require.Contains(t, unreads, o4.Id) + + // Only o1 (NotifyAll) and o3 (NotifyMention with mentions) should trigger mentions + require.Len(t, mentions, 2) + require.Contains(t, mentions, o1.Id) + require.NotContains(t, mentions, o2.Id) // NotifyMention but no mentions + require.Contains(t, mentions, o3.Id) + require.NotContains(t, mentions, o4.Id) // NotifyNone + + require.Equal(t, o1.LastPostAt, times[o1.Id]) + require.Equal(t, o2.LastPostAt, times[o2.Id]) + require.Equal(t, o3.LastPostAt, times[o3.Id]) + require.Equal(t, o4.LastPostAt, times[o4.Id]) + }) +} diff --git a/server/channels/store/storetest/mocks/ChannelStore.go b/server/channels/store/storetest/mocks/ChannelStore.go index 30ffdb0cf82..cd3749be685 100644 --- a/server/channels/store/storetest/mocks/ChannelStore.go +++ b/server/channels/store/storetest/mocks/ChannelStore.go @@ -1787,6 +1787,54 @@ func (_m *ChannelStore) GetMembersInfoByChannelIds(channelIDs []string) (map[str return r0, r1 } +// GetDirectMessagesWithUnreadAndMentions provides a mock function with given fields: rctx, userID, userNotifyProps +func (_m *ChannelStore) GetDirectMessagesWithUnreadAndMentions(rctx request.CTX, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + ret := _m.Called(rctx, userID, userNotifyProps) + + if len(ret) == 0 { + panic("no return value specified for GetDirectMessagesWithUnreadAndMentions") + } + + var r0 []string + var r1 []string + var r2 map[string]int64 + var r3 error + if rf, ok := ret.Get(0).(func(request.CTX, string, model.StringMap) ([]string, []string, map[string]int64, error)); ok { + return rf(rctx, userID, userNotifyProps) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, model.StringMap) []string); ok { + r0 = rf(rctx, userID, userNotifyProps) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, model.StringMap) []string); ok { + r1 = rf(rctx, userID, userNotifyProps) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]string) + } + } + + if rf, ok := ret.Get(2).(func(request.CTX, string, model.StringMap) map[string]int64); ok { + r2 = rf(rctx, userID, userNotifyProps) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(map[string]int64) + } + } + + if rf, ok := ret.Get(3).(func(request.CTX, string, model.StringMap) error); ok { + r3 = rf(rctx, userID, userNotifyProps) + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} + // GetMoreChannels provides a mock function with given fields: teamID, userID, offset, limit func (_m *ChannelStore) GetMoreChannels(teamID string, userID string, offset int, limit int) (model.ChannelList, error) { ret := _m.Called(teamID, userID, offset, limit) @@ -2115,6 +2163,54 @@ func (_m *ChannelStore) GetTeamChannels(teamID string) (model.ChannelList, error return r0, r1 } +// GetTeamChannelsWithUnreadAndMentions provides a mock function with given fields: rctx, teamID, userID, userNotifyProps +func (_m *ChannelStore) GetTeamChannelsWithUnreadAndMentions(rctx request.CTX, teamID string, userID string, userNotifyProps model.StringMap) ([]string, []string, map[string]int64, error) { + ret := _m.Called(rctx, teamID, userID, userNotifyProps) + + if len(ret) == 0 { + panic("no return value specified for GetTeamChannelsWithUnreadAndMentions") + } + + var r0 []string + var r1 []string + var r2 map[string]int64 + var r3 error + if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.StringMap) ([]string, []string, map[string]int64, error)); ok { + return rf(rctx, teamID, userID, userNotifyProps) + } + if rf, ok := ret.Get(0).(func(request.CTX, string, string, model.StringMap) []string); ok { + r0 = rf(rctx, teamID, userID, userNotifyProps) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string, string, model.StringMap) []string); ok { + r1 = rf(rctx, teamID, userID, userNotifyProps) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]string) + } + } + + if rf, ok := ret.Get(2).(func(request.CTX, string, string, model.StringMap) map[string]int64); ok { + r2 = rf(rctx, teamID, userID, userNotifyProps) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(map[string]int64) + } + } + + if rf, ok := ret.Get(3).(func(request.CTX, string, string, model.StringMap) error); ok { + r3 = rf(rctx, teamID, userID, userNotifyProps) + } else { + r3 = ret.Error(3) + } + + return r0, r1, r2, r3 +} + // GetTeamForChannel provides a mock function with given fields: channelID func (_m *ChannelStore) GetTeamForChannel(channelID string) (*model.Team, error) { ret := _m.Called(channelID) diff --git a/server/i18n/en.json b/server/i18n/en.json index b0abeee2be5..aa60e14c2a2 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -2636,6 +2636,10 @@ "id": "api.license_error", "translation": "api endpoint requires a license" }, + { + "id": "api.mark_all_as_read.disabled.app_error", + "translation": "Mark all as read feature is not enabled." + }, { "id": "api.marshal_error", "translation": "Failed to marshal." @@ -5502,6 +5506,10 @@ "id": "app.channel.get_channels_by_ids.not_found.app_error", "translation": "No channel found." }, + { + "id": "app.channel.get_channels_by_team_with_unreads_and_with_mentions.app_error", + "translation": "Unable to get channels with unreads and mentions." + }, { "id": "app.channel.get_channels_member_count.existing.app_error", "translation": "Unable to find member count for given channels." diff --git a/server/public/model/audit_events.go b/server/public/model/audit_events.go index 6cf11758771..f339724b6e5 100644 --- a/server/public/model/audit_events.go +++ b/server/public/model/audit_events.go @@ -458,6 +458,8 @@ const ( AuditEventLogin = "login" // user login to system AuditEventLoginWithDesktopToken = "loginWithDesktopToken" // user login to system with desktop token AuditEventLogout = "logout" // user logout from system + AuditEventMarkMessagesRead = "markAllMessagesRead" // user marked all direct and group messages as read + AuditEventMarkTeamRead = "markFullTeamRead" // user marked an entire team as read AuditEventMigrateAuthToLdap = "migrateAuthToLdap" // migrate user authentication method to LDAP AuditEventMigrateAuthToSaml = "migrateAuthToSaml" // migrate user authentication method to SAML AuditEventPatchUser = "patchUser" // update user properties diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 9f31beeecc5..72720b225ae 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -3269,6 +3269,25 @@ func (c *Client4) ReadMultipleChannels(ctx context.Context, userId string, chann return DecodeJSONFromResponse[*ChannelViewResponse](r) } +// ReadAllMessages performs a view action on all direct and group messages for a user +func (c *Client4) ReadAllMessages(ctx context.Context, userId string) (*ChannelViewResponse, *Response, error) { + r, err := c.doAPIPutJSON(ctx, c.channelsRoute().Join("members", userId, "direct", "read"), nil) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + return DecodeJSONFromResponse[*ChannelViewResponse](r) +} + +func (c *Client4) ReadAllInTeam(ctx context.Context, userId string, teamId string) (*ChannelViewResponse, *Response, error) { + r, err := c.doAPIPutJSON(ctx, c.userRoute(userId).Join("teams", teamId, "read"), nil) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + return DecodeJSONFromResponse[*ChannelViewResponse](r) +} + // 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/feature_flags.go b/server/public/model/feature_flags.go index 44a1601d7f5..ac8d06a3092 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -89,6 +89,9 @@ type FeatureFlags struct { // Mobile clients should use the direct SSO callback flow with srv parameter verification. MobileSSOCodeExchange bool + // Enable the SHIFT+ESC combo to mark _all_ chats, messages, and channels as read + EnableShiftEscapeToMarkAllRead bool + // FEATURE_FLAG_REMOVAL: AutoTranslation - Remove this when MVP is to be released // Enable auto-translation feature for messages in channels AutoTranslation bool @@ -153,6 +156,7 @@ func (f *FeatureFlags) SetDefaults() { // DEPRECATED: Disabled by default - mobile clients use direct SSO callback flow f.MobileSSOCodeExchange = false + f.EnableShiftEscapeToMarkAllRead = false f.AutoTranslation = true diff --git a/webapp/channels/src/components/channel_layout/channel_controller.test.tsx b/webapp/channels/src/components/channel_layout/channel_controller.test.tsx index ad84e9f9079..92c54084cfa 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.test.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.test.tsx @@ -24,6 +24,7 @@ jest.mock('components/channel_layout/center_channel', () => () =>
); jest.mock('components/loading_screen', () => () =>
); jest.mock('components/unreads_status_handler', () => () =>
); jest.mock('components/product_notices_modal', () => () =>
); +jest.mock('components/feature_toast/features/mark_all_as_read_toast', () => () =>
); jest.mock('plugins/pluggable', () => () =>
); jest.mock('actions/status_actions', () => ({ diff --git a/webapp/channels/src/components/channel_layout/channel_controller.tsx b/webapp/channels/src/components/channel_layout/channel_controller.tsx index 71da75b87b2..f3b3d729c14 100644 --- a/webapp/channels/src/components/channel_layout/channel_controller.tsx +++ b/webapp/channels/src/components/channel_layout/channel_controller.tsx @@ -13,6 +13,7 @@ import {getIsMobileView} from 'selectors/views/browser'; import {makeAsyncComponent} from 'components/async_load'; import CenterChannel from 'components/channel_layout/center_channel'; +import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue'; import LoadingScreen from 'components/loading_screen'; import QueryParamActionController from 'components/query_param_actions/query_param_action_controller'; import Sidebar from 'components/sidebar'; @@ -25,6 +26,7 @@ import {Constants} from 'utils/constants'; const ProductNoticesModal = makeAsyncComponent('ProductNoticesModal', lazy(() => import('components/product_notices_modal'))); const ResetStatusModal = makeAsyncComponent('ResetStatusModal', lazy(() => import('components/reset_status_modal'))); const MobileSidebarRight = makeAsyncComponent('MobileSidebarRight', lazy(() => import('components/mobile_sidebar_right'))); +const MarkAllAsReadToast = makeAsyncComponent('MarkAllAsReadToast', lazy(() => import('components/feature_toast/features/mark_all_as_read_toast'))); const BODY_CLASS_FOR_CHANNEL = ['channel-view']; @@ -35,6 +37,7 @@ type Props = { export default function ChannelController(props: Props) { const isMobileView = useSelector(getIsMobileView); const enabledUserStatuses = useSelector(getIsUserStatusesConfigEnabled); + const enableMarkAllReadShortcut = useGetFeatureFlagValue('EnableShiftEscapeToMarkAllRead') === 'true'; const dispatch = useDispatch(); useEffect(() => { @@ -80,6 +83,7 @@ export default function ChannelController(props: Props) { > + {enableMarkAllReadShortcut && }
{props.shouldRenderCenterChannel ? : } diff --git a/webapp/channels/src/components/feature_toast/feature_toast.scss b/webapp/channels/src/components/feature_toast/feature_toast.scss new file mode 100644 index 00000000000..e558cfbbd13 --- /dev/null +++ b/webapp/channels/src/components/feature_toast/feature_toast.scss @@ -0,0 +1,62 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +@use 'utils/variables'; + +.feature_toast { + position: fixed; + z-index: variables.$z-index-popover; + top: 72px; // Below channel header (56px) + margin + right: 60px; // Clear app bar (44px) + margin + display: flex; + width: 386px; + align-items: flex-start; + padding: 24px; + border: var(--border-default); + border-radius: var(--radius-s); + background-color: var(--center-channel-bg); + gap: 12px; +} + +.feature_toast__actions { + padding: 4px 0; +} + +.feature_toast__header_content { + display: flex; + width: 100%; + align-items: flex-start; + gap: 8px; + + h3 { + flex-grow: 1; + padding-top: 6px; // Align with 32px button + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 20px; + } +} + +.feature_toast__icon { + flex-shrink: 0; + margin-top: 4px; // Align with title text +} + +.feature_toast__main_content { + display: flex; + flex-direction: column; + flex-grow: 1; + align-items: baseline; + gap: 8px; + + p { + margin: 0; + } + + mark { + & + mark { + margin-left: 4px; + } + } +} diff --git a/webapp/channels/src/components/feature_toast/feature_toast.test.tsx b/webapp/channels/src/components/feature_toast/feature_toast.test.tsx new file mode 100644 index 00000000000..3d19b620518 --- /dev/null +++ b/webapp/channels/src/components/feature_toast/feature_toast.test.tsx @@ -0,0 +1,132 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import FeatureToast from './feature_toast'; + +describe('components/FeatureToast', () => { + const baseProps = { + show: true, + title: 'New Feature', + message: 'Check out this new feature!', + onDismiss: jest.fn(), + }; + + test('should render when show is true', () => { + renderWithContext(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.getByText('New Feature')).toBeInTheDocument(); + expect(screen.getByText('Check out this new feature!')).toBeInTheDocument(); + }); + + test('should not render when show is false', () => { + renderWithContext( + , + ); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + test('should render with JSX element as message', () => { + const jsxMessage = {'JSX Message with '}{'marked text'}; + renderWithContext( + , + ); + + expect(screen.getByText('JSX Message with')).toBeInTheDocument(); + expect(screen.getByText('marked text')).toBeInTheDocument(); + }); + + test('should have correct ARIA attributes', () => { + renderWithContext(); + + const toast = screen.getByRole('status'); + expect(toast).toHaveAttribute('aria-live', 'polite'); + expect(toast).toHaveAttribute('aria-atomic', 'true'); + }); + + test('should have accessible close button', () => { + renderWithContext(); + + const closeButton = screen.getByRole('button', {name: /close/i}); + expect(closeButton).toBeInTheDocument(); + expect(closeButton).toHaveAttribute('aria-label', 'Close'); + }); + + test('should call onDismiss when close button is clicked', async () => { + const onDismiss = jest.fn(); + renderWithContext( + , + ); + + const closeButton = screen.getByRole('button', {name: /close/i}); + await userEvent.click(closeButton); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + test('should render action button when showButton is true', () => { + renderWithContext( + , + ); + + expect(screen.getByRole('button', {name: 'Learn More'})).toBeInTheDocument(); + }); + + test('should not render action button when showButton is false', () => { + renderWithContext( + , + ); + + expect(screen.queryByRole('button', {name: 'Learn More'})).not.toBeInTheDocument(); + }); + + test('should not render action button when showButton is undefined', () => { + renderWithContext(); + + // Should only have the close button + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(1); + expect(buttons[0]).toHaveAttribute('aria-label', 'Close'); + }); + + test('should call onDismiss when action button is clicked', async () => { + const onDismiss = jest.fn(); + renderWithContext( + , + ); + + const actionButton = screen.getByRole('button', {name: 'Got it'}); + await userEvent.click(actionButton); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/webapp/channels/src/components/feature_toast/feature_toast.tsx b/webapp/channels/src/components/feature_toast/feature_toast.tsx new file mode 100644 index 00000000000..1210feabfc7 --- /dev/null +++ b/webapp/channels/src/components/feature_toast/feature_toast.tsx @@ -0,0 +1,93 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {FloatingPortal} from '@floating-ui/react'; +import React from 'react'; +import {useIntl} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {PlaylistCheckIcon, CloseIcon} from '@mattermost/compass-icons/components'; +import {WithTooltip} from '@mattermost/shared/components/tooltip'; + +import {isAnyModalOpen} from 'selectors/views/modals'; + +import {RootHtmlPortalId} from 'utils/constants'; + +import './feature_toast.scss'; + +type Props = { + show: boolean; + title: string; + message: string | JSX.Element; + showButton?: boolean; + buttonText?: string; + onDismiss: () => void; +}; + +export default function FeatureToast({ + show, + title, + message, + showButton, + buttonText, + onDismiss, +}: Props) { + const {formatMessage} = useIntl(); + const anyModalOpen = useSelector(isAnyModalOpen); + + if (!show || anyModalOpen) { + return null; + } + + const handleDismiss = () => { + onDismiss(); + }; + + return ( + +
+ +
+
+

{title}

+ + + +
+

{message}

+
+ {showButton && ( + + )} +
+
+
+
+ ); +} diff --git a/webapp/channels/src/components/feature_toast/features/mark_all_as_read_toast.tsx b/webapp/channels/src/components/feature_toast/features/mark_all_as_read_toast.tsx new file mode 100644 index 00000000000..9ef5be36608 --- /dev/null +++ b/webapp/channels/src/components/feature_toast/features/mark_all_as_read_toast.tsx @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {ShortcutKeys} from '@mattermost/shared/components/shortcut_key'; +import * as UserAgent from '@mattermost/shared/utils/user_agent'; + +import {Preferences} from 'mattermost-redux/constants'; + +import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue'; +import usePreference from 'components/common/hooks/usePreference'; +import {ShortcutSequence, ShortcutKeyVariant} from 'components/shortcut_sequence'; + +import FeatureToast from '../feature_toast'; + +export default function MarkAllAsReadToast() { + const {formatMessage} = useIntl(); + const enableMarkAllReadShortcut = useGetFeatureFlagValue('EnableShiftEscapeToMarkAllRead') === 'true'; + const [userHasSeenMarkAllReadToast, setUserHasSeenMarkAllReadToast] = usePreference( + Preferences.CATEGORY_NEW_FEATURES, + Preferences.HAS_SEEN_MARK_ALL_READ_FEATURE, + ); + const [show, setShow] = useState( + enableMarkAllReadShortcut && + !UserAgent.isMobile() && + !userHasSeenMarkAllReadToast, + ); + + if (!enableMarkAllReadShortcut) { + return null; + } + + const onDismiss = () => { + setShow(false); + setUserHasSeenMarkAllReadToast('true'); + }; + + const titleText = formatMessage({ + id: 'mark_all_as_read_toast.title', + defaultMessage: 'A new shortcut to clear unreads', + }); + + const message = ( + + ), + }} + /> + ); + + const buttonText = formatMessage({ + id: 'mark_all_as_read_toast.button', + defaultMessage: 'Got it', + }); + + return ( + + ); +} diff --git a/webapp/channels/src/components/feature_toast/index.ts b/webapp/channels/src/components/feature_toast/index.ts new file mode 100644 index 00000000000..2091939f6e8 --- /dev/null +++ b/webapp/channels/src/components/feature_toast/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export class FeaturesToAnnounce { + static MARK_ALL_AS_READ_SHORTCUT = 'mark_all_as_read_shortcut'; +} + +export const createHasSeenFeatureSuffix = (userId: string, featureName: string) => + `${userId}_${featureName}`; diff --git a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.test.tsx b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.test.tsx index 838593536b8..d4a6b1c082a 100644 --- a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.test.tsx +++ b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.test.tsx @@ -10,6 +10,11 @@ import {suitePluginIds} from 'utils/constants'; describe('components/KeyboardShortcutsModal', () => { const initialState = { + entities: { + general: { + config: {}, + }, + }, plugins: { plugins: {}, }, diff --git a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx index 432f4c270e4..08c1c606bbf 100644 --- a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx +++ b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_modal/keyboard_shortcuts_modal.tsx @@ -10,6 +10,7 @@ import * as UserAgent from '@mattermost/shared/utils/user_agent'; import {isCallsEnabled} from 'selectors/calls'; +import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue'; import KeyboardShortcutSequence, { KEYBOARD_SHORTCUTS, } from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence'; @@ -85,6 +86,7 @@ interface Props { const KeyboardShortcutsModal = ({onExited}: Props): JSX.Element => { const [show, setShow] = useState(true); const contentRef = useRef(null); + const enableMarkAllReadShortcut = useGetFeatureFlagValue('EnableShiftEscapeToMarkAllRead') === 'true'; const {formatMessage} = useIntl(); @@ -163,6 +165,7 @@ const KeyboardShortcutsModal = ({onExited}: Props): JSX.Element => {

{formatMessage(modalMessages.msgHeader)}

+ {enableMarkAllReadShortcut && }

{formatMessage(modalMessages.msgInputHeader)}

diff --git a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts index d093716cf0f..ac8e70626de 100644 --- a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts +++ b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts @@ -253,6 +253,10 @@ export const KEYBOARD_SHORTCUTS = { defaultMessage: 'Toggle unread/all channels:\t⌘|Shift|U', }, }), + markAllRead: defineMessage({ + id: 'shortcuts.msgs.mark_all_read', + defaultMessage: 'Mark all messages as read:\tShift|Esc', + }), msgEdit: defineMessage({ id: 'shortcuts.msgs.edit', defaultMessage: 'Edit last message in channel:\tUp', diff --git a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx index 518408e7f08..7f975265443 100644 --- a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx +++ b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts_sequence.tsx @@ -4,12 +4,13 @@ import React, {memo} from 'react'; import {useIntl} from 'react-intl'; -import {ShortcutKeyVariant, ShortcutKey} from '@mattermost/shared/components/shortcut_key'; import {isMac} from '@mattermost/shared/utils/user_agent'; +import {ShortcutSequence, ShortcutKeyVariant, KEY_SEPARATOR} from 'components/shortcut_sequence'; + import {isMessageDescriptor} from 'utils/i18n'; -import type {KeyboardShortcutDescriptor} from './keyboard_shortcuts'; +import {type KeyboardShortcutDescriptor} from './keyboard_shortcuts'; import './keyboard_shortcuts_sequence.scss'; @@ -28,12 +29,11 @@ function normalizeShortcutDescriptor(shortcut: KeyboardShortcutDescriptor) { return isMac() && mac ? mac : standard; } -const KEY_SEPARATOR = '|'; - function KeyboardShortcutSequence({shortcut, hideDescription, hoistDescription, isInsideTooltip}: Props) { const {formatMessage} = useIntl(); const shortcutText = formatMessage(normalizeShortcutDescriptor(shortcut)); const splitShortcut = shortcutText.split('\t'); + const variant = isInsideTooltip ? ShortcutKeyVariant.Tooltip : ShortcutKeyVariant.ShortcutModal; let description = ''; let keys = ''; @@ -50,19 +50,13 @@ function KeyboardShortcutSequence({shortcut, hideDescription, hoistDescription, } const renderAltKeys = () => { - const shortcutKeys = altKeys.split(KEY_SEPARATOR).map((key) => ( - - {key} - - )); - return ( <> {'\t|\t'} - {shortcutKeys} + ); }; @@ -72,14 +66,12 @@ function KeyboardShortcutSequence({shortcut, hideDescription, hoistDescription, {hoistDescription && !hideDescription && description?.replace(/:{1,2}$/, '')}
{!hoistDescription && !hideDescription && description && {description}} - {keys && keys.split(KEY_SEPARATOR).map((key) => ( - - {key} - - ))} + {keys && ( + + )} {altKeys && renderAltKeys()}
diff --git a/webapp/channels/src/components/mark_all_as_read_modal.scss b/webapp/channels/src/components/mark_all_as_read_modal.scss new file mode 100644 index 00000000000..61f1937b8cf --- /dev/null +++ b/webapp/channels/src/components/mark_all_as_read_modal.scss @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.mark_all_as_read_modal { + width: 512px; + + .modal-body { + margin-bottom: 48px; + } + + .modal-header { + min-height: 56px; + padding-bottom: 0; + } +} + +.mark_all_as_read_modal__body { + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 48px 32px; + text-align: center; + + h2 { + margin-top: 0; + font-size: 22px; + font-weight: 600; + } + + mark + mark { + margin-left: 4px; + } + + .checkbox { + margin-top: 16px; + } +} + +.mark_all_as_read_modal__footer { + display: flex; + justify-content: center; + gap: 10px; +} diff --git a/webapp/channels/src/components/mark_all_as_read_modal.test.tsx b/webapp/channels/src/components/mark_all_as_read_modal.test.tsx new file mode 100644 index 00000000000..1e41ceccb6c --- /dev/null +++ b/webapp/channels/src/components/mark_all_as_read_modal.test.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import MarkAllAsReadModal from './mark_all_as_read_modal'; + +describe('components/MarkAllAsReadModal', () => { + const baseProps = { + onConfirm: jest.fn(), + onHide: jest.fn(), + }; + + test('should render modal content', () => { + renderWithContext(); + + expect(screen.getByText('Mark all messages as read?')).toBeInTheDocument(); + expect(screen.getByText(/will mark all messages as read/i)).toBeInTheDocument(); + }); + + test('should render checkbox with correct label', () => { + renderWithContext(); + + const checkbox = screen.getByRole('checkbox', {name: /Don't ask me again/}); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + }); + + test('should toggle checkbox state when clicked', async () => { + renderWithContext(); + + const checkbox = screen.getByRole('checkbox', {name: /Don't ask me again/}); + expect(checkbox).not.toBeChecked(); + + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + + await userEvent.click(checkbox); + expect(checkbox).not.toBeChecked(); + }); + + test('should render cancel and confirm buttons', () => { + renderWithContext(); + + expect(screen.getByRole('button', {name: 'Cancel'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Mark all read'})).toBeInTheDocument(); + }); + + test('should call onHide when cancel button is clicked', async () => { + const onHide = jest.fn(); + renderWithContext( + , + ); + + const cancelButton = screen.getByRole('button', {name: 'Cancel'}); + await userEvent.click(cancelButton); + + expect(onHide).toHaveBeenCalledTimes(1); + }); + + test('should call onConfirm with false when confirm button is clicked without checkbox', async () => { + const onConfirm = jest.fn(); + renderWithContext( + , + ); + + const confirmButton = screen.getByRole('button', {name: 'Mark all read'}); + await userEvent.click(confirmButton); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith(false); + }); + + test('should call onConfirm with true when confirm button is clicked with checkbox checked', async () => { + const onConfirm = jest.fn(); + renderWithContext( + , + ); + + const checkbox = screen.getByRole('checkbox', {name: /Don't ask me again/}); + await userEvent.click(checkbox); + + const confirmButton = screen.getByRole('button', {name: 'Mark all read'}); + await userEvent.click(confirmButton); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith(true); + }); + + test('should reset checkbox state when cancel is clicked', async () => { + renderWithContext(); + + const checkbox = screen.getByRole('checkbox', {name: /Don't ask me again/}); + await userEvent.click(checkbox); + expect(checkbox).toBeChecked(); + + const cancelButton = screen.getByRole('button', {name: 'Cancel'}); + await userEvent.click(cancelButton); + + // Re-render to check state after cancel + const {rerender} = renderWithContext(); + rerender(); + + const checkboxAfterCancel = screen.getByRole('checkbox', {name: /Don't ask me again/}); + expect(checkboxAfterCancel).not.toBeChecked(); + }); +}); diff --git a/webapp/channels/src/components/mark_all_as_read_modal.tsx b/webapp/channels/src/components/mark_all_as_read_modal.tsx new file mode 100644 index 00000000000..c9cea8be315 --- /dev/null +++ b/webapp/channels/src/components/mark_all_as_read_modal.tsx @@ -0,0 +1,121 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {GenericModal} from '@mattermost/components'; +import {ShortcutKeys} from '@mattermost/shared/components/shortcut_key'; + +import {ShortcutSequence, ShortcutKeyVariant} from './shortcut_sequence'; + +import './mark_all_as_read_modal.scss'; + +export type Props = { + onConfirm: (dontAskAgain: boolean) => void; + onExited?: () => void; + onHide?: () => void; +} + +export default function MarkAllAsReadModal({ + onConfirm, + onExited, + onHide, +}: Props) { + const [checked, setChecked] = useState(false); + + const title = ( + + ); + + const handleClose = () => { + setChecked(false); + onHide?.(); + }; + + const handleConfirm = () => { + onConfirm(checked); + onHide?.(); + }; + + const message = ( + + ), + }} + /> + ); + + const checkboxText = ( + + ); + + const checkbox = ( +
+ +
+ ); + + const cancelButtonText = ( + + ); + + const confirmButtonText = ( + + ); + + return ( + +
+

{title}

+

{message}

+ {checkbox} +
+
+ + +
+
+ ); +} diff --git a/webapp/channels/src/components/shortcut_sequence/index.ts b/webapp/channels/src/components/shortcut_sequence/index.ts new file mode 100644 index 00000000000..aec6e67e49c --- /dev/null +++ b/webapp/channels/src/components/shortcut_sequence/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export * from './shortcut_sequence'; diff --git a/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.test.tsx b/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.test.tsx new file mode 100644 index 00000000000..3060e726438 --- /dev/null +++ b/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.test.tsx @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {ShortcutKeyVariant} from '@mattermost/shared/components/shortcut_key'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; + +import {ShortcutSequence, KEY_SEPARATOR} from './shortcut_sequence'; + +describe('ShortcutSequence', () => { + test('should render single key from string', () => { + renderWithContext(); + + expect(screen.getByText('Ctrl')).toBeInTheDocument(); + }); + + test('should render multiple keys from pipe-separated string', () => { + renderWithContext(); + + expect(screen.getByText('Ctrl')).toBeInTheDocument(); + expect(screen.getByText('K')).toBeInTheDocument(); + }); + + test('should render keys from array of strings', () => { + renderWithContext(); + + expect(screen.getByText('Shift')).toBeInTheDocument(); + expect(screen.getByText('Enter')).toBeInTheDocument(); + }); + + test('should render keys from array with message descriptors', () => { + const keys = [ + /* defineMessage */({ + id: 'test.ctrl', + defaultMessage: 'Ctrl', + }), + 'K', + ]; + + renderWithContext(); + + expect(screen.getByText('Ctrl')).toBeInTheDocument(); + expect(screen.getByText('K')).toBeInTheDocument(); + }); + + test('should apply tooltip variant class', () => { + renderWithContext( + , + ); + + const keyElement = screen.getByText('K'); + expect(keyElement).toHaveClass('shortcut-key--tooltip'); + }); + + test('should apply contrast variant class', () => { + renderWithContext( + , + ); + + const keyElement = screen.getByText('K'); + expect(keyElement).toHaveClass('shortcut-key--contrast'); + }); +}); diff --git a/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.tsx b/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.tsx new file mode 100644 index 00000000000..8ddd7ee4d51 --- /dev/null +++ b/webapp/channels/src/components/shortcut_sequence/shortcut_sequence.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import type {MessageDescriptor} from 'react-intl'; +import {FormattedMessage} from 'react-intl'; + +import {ShortcutKey, ShortcutKeyVariant} from '@mattermost/shared/components/shortcut_key'; + +import {isMessageDescriptor} from 'utils/i18n'; + +export const KEY_SEPARATOR = '|'; + +export {ShortcutKeyVariant}; + +export type ShortcutKeyDescriptor = string | MessageDescriptor; + +export type ShortcutSequenceProps = { + keys: string | ShortcutKeyDescriptor[]; + variant?: ShortcutKeyVariant; +}; + +export const ShortcutSequence = ({keys, variant}: ShortcutSequenceProps) => { + const keysArr = typeof keys === 'string' ? keys.split(KEY_SEPARATOR) : keys; + + return ( + <> + {keysArr.map((shortcutKey) => { + let key; + let content; + if (isMessageDescriptor(shortcutKey)) { + key = shortcutKey.id; + content = ; + } else { + key = shortcutKey; + content = shortcutKey; + } + + return ( + + {content} + + ); + })} + + ); +}; diff --git a/webapp/channels/src/components/sidebar/sidebar_list/index.ts b/webapp/channels/src/components/sidebar/sidebar_list/index.ts index afd9a2a0bf8..76990dfb27d 100644 --- a/webapp/channels/src/components/sidebar/sidebar_list/index.ts +++ b/webapp/channels/src/components/sidebar/sidebar_list/index.ts @@ -5,11 +5,19 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import type {Dispatch} from 'redux'; +import type {PreferenceType} from '@mattermost/types/preferences'; + import {moveCategory} from 'mattermost-redux/actions/channel_categories'; +import {readAllMessages} from 'mattermost-redux/actions/channels'; +import {savePreferences} from 'mattermost-redux/actions/preferences'; +import {markAllInTeamAsRead} from 'mattermost-redux/actions/teams'; +import {Preferences} from 'mattermost-redux/constants'; import {getCurrentChannelId, getUnreadChannelIds} from 'mattermost-redux/selectors/entities/channels'; -import {shouldShowUnreadsCategory, isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general'; +import {shouldShowUnreadsCategory, isCollapsedThreadsEnabled, get as getPreference} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import {getThreadCountsInCurrentTeam} from 'mattermost-redux/selectors/entities/threads'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {switchToChannelById} from 'actions/views/channel'; import { @@ -19,6 +27,7 @@ import { clearChannelSelection, } from 'actions/views/channel_sidebar'; import {close, switchToLhsStaticPage} from 'actions/views/lhs'; +import {openModal} from 'actions/views/modals'; import {getCurrentStaticPageId, getVisibleStaticPages} from 'selectors/lhs'; import { getDisplayedChannels, @@ -34,6 +43,8 @@ import SidebarList from './sidebar_list'; function mapStateToProps(state: GlobalState) { const currentTeam = getCurrentTeam(state); const collapsedThreads = isCollapsedThreadsEnabled(state); + const getmarkAllAsReadWithoutConfirm = (state: GlobalState) => + getPreference(state, Preferences.CATEGORY_SHORTCUT_ACTIONS, Preferences.MARK_ALL_READ_WITHOUT_CONFIRM, 'false'); let hasUnreadThreads = false; if (collapsedThreads) { @@ -42,6 +53,7 @@ function mapStateToProps(state: GlobalState) { return { currentTeam, + currentUserId: getCurrentUserId(state), currentChannelId: getCurrentChannelId(state), categories: getCategoriesForCurrentTeam(state), isUnreadFilterEnabled: isUnreadFilterEnabled(state), @@ -53,12 +65,24 @@ function mapStateToProps(state: GlobalState) { showUnreadsCategory: shouldShowUnreadsCategory(state), collapsedThreads, hasUnreadThreads, + markAllAsReadWithoutConfirm: getmarkAllAsReadWithoutConfirm(state) === 'true', + markAllAsReadShortcutEnabled: getFeatureFlagValue(state, 'EnableShiftEscapeToMarkAllRead') === 'true', currentStaticPageId: getCurrentStaticPageId(state), staticPages: getVisibleStaticPages(state), }; } function mapDispatchToProps(dispatch: Dispatch) { + const setMarkAllAsReadWithoutConfirm = (userId: string, value: boolean) => { + const preference: PreferenceType = { + category: Preferences.CATEGORY_SHORTCUT_ACTIONS, + name: Preferences.MARK_ALL_READ_WITHOUT_CONFIRM, + user_id: userId, + value: String(value), + }; + return savePreferences(userId, [preference]); + }; + return { actions: bindActionCreators({ close, @@ -69,6 +93,10 @@ function mapDispatchToProps(dispatch: Dispatch) { stopDragging, clearChannelSelection, switchToLhsStaticPage, + readAllMessages, + markAllInTeamAsRead, + setMarkAllAsReadWithoutConfirm, + openModal, }, dispatch), }; } diff --git a/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.test.tsx b/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.test.tsx index 1cb8828430b..63ded333208 100644 --- a/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.test.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.test.tsx @@ -131,6 +131,7 @@ describe('SidebarList', () => { }, ], unreadChannelIds: ['channel_id_2'], + currentUserId: 'current_user_id', displayedChannels: [currentChannel, unreadChannel], newCategoryIds: [], multiSelectedChannelIds: [], @@ -143,6 +144,8 @@ describe('SidebarList', () => { showUnreadsCategory: false, collapsedThreads: true, hasUnreadThreads: false, + markAllAsReadWithoutConfirm: false, + markAllAsReadShortcutEnabled: false, currentStaticPageId: '', staticPages: [], actions: { @@ -156,6 +159,10 @@ describe('SidebarList', () => { stopDragging: jest.fn(), clearChannelSelection: jest.fn(), multiSelectChannelAdd: jest.fn(), + readAllMessages: jest.fn(), + markAllInTeamAsRead: jest.fn(), + setMarkAllAsReadWithoutConfirm: jest.fn(), + openModal: jest.fn(), }, }; 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 a76e5850b9e..0d7dfcefc0a 100644 --- a/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_list/sidebar_list.tsx @@ -19,13 +19,16 @@ import {CategoryTypes} from 'mattermost-redux/constants/channel_categories'; import {makeAsyncComponent} from 'components/async_load'; import Scrollbars from 'components/common/scrollbars'; +import MarkAllAsReadModal from 'components/mark_all_as_read_modal'; +import type {Props as MarkAllAsReadModalProps} from 'components/mark_all_as_read_modal'; import SidebarCategory from 'components/sidebar/sidebar_category'; import {findNextUnreadChannelId} from 'utils/channel_utils'; -import {Constants, DraggingStates, DraggingStateTypes} from 'utils/constants'; +import {Constants, DraggingStates, DraggingStateTypes, ModalIdentifiers} from 'utils/constants'; import {isKeyPressed, cmdOrCtrlPressed} from 'utils/keyboard'; import {mod} from 'utils/utils'; +import type {ModalData} from 'types/actions'; import type {DraggingState} from 'types/store'; import type {StaticPage} from 'types/store/lhs'; @@ -37,6 +40,7 @@ const UnreadChannels = makeAsyncComponent('UnreadChannels', lazy(() => import('. type Props = WrappedComponentProps & { currentTeam?: Team; + currentUserId: string; currentChannelId: string; categories: ChannelCategory[]; unreadChannelIds: string[]; @@ -54,6 +58,8 @@ type Props = WrappedComponentProps & { handleOpenMoreDirectChannelsModal: (e: Event) => void; onDragStart: (initial: DragStart) => void; onDragEnd: (result: DropResult) => void; + markAllAsReadWithoutConfirm: boolean; + markAllAsReadShortcutEnabled: boolean; actions: { moveChannelsInSidebar: (categoryId: string, targetIndex: number, draggableChannelId: string) => void; @@ -64,6 +70,10 @@ type Props = WrappedComponentProps & { setDraggingState: (data: DraggingState) => void; stopDragging: () => void; clearChannelSelection: () => void; + readAllMessages: (userId: string) => void; + markAllInTeamAsRead: (userId: string, teamId: string) => void; + setMarkAllAsReadWithoutConfirm: (userId: string, value: boolean) => void; + openModal:

(modalData: ModalData

) => void; }; }; @@ -107,11 +117,17 @@ export class SidebarList extends React.PureComponent { componentDidMount() { document.addEventListener('keydown', this.navigateChannelShortcut); document.addEventListener('keydown', this.navigateUnreadChannelShortcut); + if (this.props.markAllAsReadShortcutEnabled) { + document.addEventListener('keydown', this.markAllChannelsAsReadShortcut); + } } componentWillUnmount() { document.removeEventListener('keydown', this.navigateChannelShortcut); document.removeEventListener('keydown', this.navigateUnreadChannelShortcut); + if (this.props.markAllAsReadShortcutEnabled) { + document.removeEventListener('keydown', this.markAllChannelsAsReadShortcut); + } } componentDidUpdate(prevProps: Props) { @@ -340,6 +356,23 @@ export class SidebarList extends React.PureComponent { } }; + markAllChannelsAsReadShortcut = (e: KeyboardEvent) => { + if (!e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey && isKeyPressed(e, Constants.KeyCodes.ESCAPE)) { + e.preventDefault(); + if (this.props.markAllAsReadWithoutConfirm) { + this.markAllAsRead(); + } else { + this.props.actions.openModal({ + modalId: ModalIdentifiers.MARK_ALL_AS_READ, + dialogType: MarkAllAsReadModal, + dialogProps: { + onConfirm: this.onMarkAllAsReadConfirm, + }, + }); + } + } + }; + renderCategory = (category: ChannelCategory, index: number) => { return ( { this.props.actions.stopDragging(); }; + hasAnyUnreads = () => { + return this.props.unreadChannelIds.length > 0 || this.props.hasUnreadThreads; + }; + + markAllAsRead = () => { + if (this.hasAnyUnreads()) { + // I'm not sure if a user can ever _not_ be in a team, but this just + // feels safe in case that functionality is ever introduced, so the + // hotkey still marks all DMs as read. + if (this.props.currentTeam?.id) { + this.props.actions.markAllInTeamAsRead(this.props.currentUserId, this.props.currentTeam.id); + } + this.props.actions.readAllMessages(this.props.currentUserId); + } + }; + + onMarkAllAsReadConfirm = (dontShowAgain: boolean) => { + this.markAllAsRead(); + this.props.actions.setMarkAllAsReadWithoutConfirm( + this.props.currentUserId, + dontShowAgain, + ); + }; + render() { const {categories} = this.props; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 99da925ca60..f2f6c7e3251 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4691,6 +4691,7 @@ "feature_restricted_modal.agreement": "By selecting Try free for {trialLength} days, I agree to the Mattermost Software Evaluation Agreement, Privacy Policy, and receiving product emails.", "feature_restricted_modal.button.notify": "Notify admin", "feature_restricted_modal.button.plans": "View plans", + "feature_toast.tooltipCloseBtn": "Close", "feedback.cancelButton.text": "Cancel", "feedback.downgradeWorkspace.downgrade": "Downgrade", "feedback.downgradeWorkspace.exploringOptions": "Exploring other solutions", @@ -5374,6 +5375,14 @@ "manage_team_groups_modal.search_placeholder": "Search groups", "managed_category.label": "Managed category (optional)", "managed_category.placeholder": "Choose a managed category (optional)", + "mark_all_as_read_modal.cancel": "Cancel", + "mark_all_as_read_modal.checkbox": "Don't ask me again", + "mark_all_as_read_modal.confirm": "Mark all read", + "mark_all_as_read_modal.message": "{shortcut} will mark all messages as read in channels, threads, and Direct Messages for this team. Are you sure?", + "mark_all_as_read_modal.title": "Mark all messages as read?", + "mark_all_as_read_toast.button": "Got it", + "mark_all_as_read_toast.message": "Now you can use {shortcut} to mark all of your messages for this team as read. Don't worry, you'll be asked to confirm.", + "mark_all_as_read_toast.title": "A new shortcut to clear unreads", "mark_all_threads_as_read_modal.confirm": "Mark all as read", "mark_all_threads_as_read_modal.description": "All your threads will be marked as read, with unread and mention badges cleared. Do you want to continue?", "mark_all_threads_as_read_modal.title": "Mark all your threads as read", @@ -6224,6 +6233,7 @@ "shortcuts.msgs.formatting_bar.post_priority": "Message priority", "shortcuts.msgs.header": "Messages", "shortcuts.msgs.input.header": "Works inside an empty input field", + "shortcuts.msgs.mark_all_read": "Mark all messages as read:\tShift|Esc", "shortcuts.msgs.markdown.bold": "Bold:\tCtrl|B", "shortcuts.msgs.markdown.bold.mac": "Bold:\t⌘|B", "shortcuts.msgs.markdown.code": "Code:\tCtrl|Alt|C", 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 2c4ae1d4664..6c442b82df1 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -739,6 +739,23 @@ export function unsetActiveChannelOnServer(): ActionFuncAsync { }; } +export function readAllMessages(userId: string): ActionFuncAsync { + return async (dispatch, getState) => { + let response; + try { + response = await Client4.markAllMessagesAsRead(userId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch(markMultipleChannelsAsRead(response.last_viewed_at_times)); + + return {data: true}; + }; +} + export function readMultipleChannels(channelIds: string[]): ActionFuncAsync { return async (dispatch, getState) => { let response; diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts index 13a81caf94f..74bbd1cb88e 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/teams.ts @@ -9,7 +9,7 @@ import type {Team, TeamMembership, TeamMemberWithError, GetTeamMembersOpts, Team import type {UserProfile} from '@mattermost/types/users'; import {ChannelTypes, TeamTypes, UserTypes} from 'mattermost-redux/action_types'; -import {selectChannel} from 'mattermost-redux/actions/channels'; +import {markMultipleChannelsAsRead, selectChannel} from 'mattermost-redux/actions/channels'; import {logError} from 'mattermost-redux/actions/errors'; import {bindClientFunc, forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers'; import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles'; @@ -22,6 +22,8 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import type {ActionResult, DispatchFunc, GetStateFunc, ActionFuncAsync} from 'mattermost-redux/types/actions'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; +import {handleAllMarkedRead} from './threads'; + async function getProfilesAndStatusesForMembers(userIds: string[], dispatch: DispatchFunc, getState: GetStateFunc) { const state = getState(); const { @@ -730,3 +732,21 @@ export function updateNoticesAsViewed(noticeIds: string[]) { ], }); } + +export function markAllInTeamAsRead(userId: string, teamId: string): ActionFuncAsync { + return async (dispatch, getState) => { + let response; + try { + response = await Client4.markAllInTeamAsRead(userId, teamId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch(markMultipleChannelsAsRead(response.last_viewed_at_times)); + dispatch(handleAllMarkedRead(teamId)); + + return {data: response}; + }; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts index 60ea89f7954..84667e683a8 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/constants/preferences.ts @@ -55,6 +55,12 @@ const Preferences = { CATEGORY_WHATS_NEW_MODAL: 'whats_new_modal', HAS_SEEN_SIDEBAR_WHATS_NEW_MODAL: 'has_seen_sidebar_whats_new_modal', + CATEGORY_SHORTCUT_ACTIONS: 'shortcut_actions', + MARK_ALL_READ_WITHOUT_CONFIRM: 'mark_all_read_without_confirm', + + CATEGORY_NEW_FEATURES: 'new_features', + HAS_SEEN_MARK_ALL_READ_FEATURE: 'mark_all_read_seen', + CATEGORY_PERFORMANCE_DEBUGGING: 'performance_debugging', NAME_DISABLE_CLIENT_PLUGINS: 'disable_client_plugins', NAME_DISABLE_TYPING_MESSAGES: 'disable_typing_messages', diff --git a/webapp/channels/src/stores/hooks.ts b/webapp/channels/src/stores/hooks.ts index f8a339c25ab..84221b4db36 100644 --- a/webapp/channels/src/stores/hooks.ts +++ b/webapp/channels/src/stores/hooks.ts @@ -35,7 +35,7 @@ export function useGlobalState( const dispatch = useDispatch(); const defaultSuffix = useSelector(currentUserAndTeamSuffix); const suffixToUse = suffix || defaultSuffix; - const storedKey = `${name}${suffixToUse}`; + const storedKey = createStoredKey(name, suffixToUse); const value = useSelector(makeGetGlobalItem(storedKey, initialValue), shallowEqual); const setValue = useCallback((newValue: TVal) => dispatch(setGlobalItem(storedKey, newValue)), [storedKey]); @@ -45,3 +45,10 @@ export function useGlobalState( setValue, ]; } + +/** + * This seems verbose, but it is or use with + * existing class components.They can't use hooks, + * but will still want to have the same format as the hook. + */ +export const createStoredKey = (name: string, suffixToUse?: string) => `${name}${suffixToUse ?? ''}`; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 92956f208a4..d1cc00213f9 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -415,6 +415,7 @@ export const ModalIdentifiers = { POST_DELETED_MODAL: 'post_deleted_modal', FILE_PREVIEW_MODAL: 'file_preview_modal', LEAVE_PRIVATE_CHANNEL_MODAL: 'leave_private_channel_modal', + MARK_ALL_AS_READ: 'mark_all_as_read', GET_PUBLIC_LINK_MODAL: 'get_public_link_modal', KEYBOARD_SHORTCUTS_MODAL: 'keyboar_shortcuts_modal', USERS_TO_BE_REMOVED: 'users_to_be_removed', @@ -813,6 +814,8 @@ export const StoragePrefixes = { DELINQUENCY: 'delinquency_', HIDE_JOINED_CHANNELS: 'hideJoinedChannels', HIDE_NOTIFICATION_PERMISSION_REQUEST_BANNER: 'hideNotificationPermissionRequestBanner', + MARK_ALL_READ_WITHOUT_CONFIRM: 'mark_all_as_read_without_confirm', + HAS_SEEN_FEATURE_TOAST: 'has_seen_feature_toast', }; export const LandingPreferenceTypes = { diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 0b3a36ae540..9d8124e1e29 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -2014,6 +2014,20 @@ export default class Client4 { ); }; + markAllInTeamAsRead = (userId: string, teamId: string) => { + return this.doFetch( + `${this.getUserRoute(userId)}/teams/${teamId}/read`, + {method: 'put'}, + ); + }; + + markAllMessagesAsRead = (userId: string) => { + return this.doFetch( + `${this.getChannelsRoute()}/members/${userId}/direct/read`, + {method: 'put'}, + ); + }; + autocompleteChannels = (teamId: string, name: string) => { return this.doFetch( `${this.getTeamRoute(teamId)}/channels/autocomplete${buildQueryString({name})}`, diff --git a/webapp/platform/shared/src/components/shortcut_key/keys.ts b/webapp/platform/shared/src/components/shortcut_key/keys.ts index dffaefd2b21..9accb3f0286 100644 --- a/webapp/platform/shared/src/components/shortcut_key/keys.ts +++ b/webapp/platform/shared/src/components/shortcut_key/keys.ts @@ -17,6 +17,10 @@ export const ShortcutKeys = { id: 'shortcuts.generic.enter', defaultMessage: 'Enter', }), + escape: defineMessage({ + id: 'general_button.esc', + defaultMessage: 'Esc', + }), option: '⌥', shift: defineMessage({ id: 'shortcuts.generic.shift', diff --git a/webapp/platform/shared/src/components/shortcut_key/shortcut_key.css b/webapp/platform/shared/src/components/shortcut_key/shortcut_key.css index d021374ffb5..d91050a9c33 100644 --- a/webapp/platform/shared/src/components/shortcut_key/shortcut_key.css +++ b/webapp/platform/shared/src/components/shortcut_key/shortcut_key.css @@ -39,4 +39,14 @@ font-weight: 600; line-height: 16px; } + + &.shortcut-key--inline-content { + padding: 2px 5px; + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), 0.75); + font-family: inherit; + font-size: 12px; + font-weight: 600; + line-height: 16px; + } } diff --git a/webapp/platform/shared/src/components/shortcut_key/shortcut_key.tsx b/webapp/platform/shared/src/components/shortcut_key/shortcut_key.tsx index 792215cc9d2..5234d0ba974 100644 --- a/webapp/platform/shared/src/components/shortcut_key/shortcut_key.tsx +++ b/webapp/platform/shared/src/components/shortcut_key/shortcut_key.tsx @@ -11,6 +11,7 @@ export enum ShortcutKeyVariant { Tooltip = 'tooltip', TutorialTip = 'tutorialTip', ShortcutModal = 'shortcut', + InlineContent = 'inline-content', } export interface ShortcutKeyProps { @@ -26,6 +27,7 @@ export function ShortcutKey({children, variant}: ShortcutKeyProps) { 'shortcut-key--tooltip': variant === ShortcutKeyVariant.Tooltip, 'shortcut-key--tutorial-tip': variant === ShortcutKeyVariant.TutorialTip, 'shortcut-key--shortcut-modal': variant === ShortcutKeyVariant.ShortcutModal, + 'shortcut-key--inline-content': variant === ShortcutKeyVariant.InlineContent, })} > {children} diff --git a/webapp/platform/shared/src/components/tooltip/tooltip_shortcut.test.tsx b/webapp/platform/shared/src/components/tooltip/tooltip_shortcut.test.tsx index 4588c263015..e1ad4a45667 100644 --- a/webapp/platform/shared/src/components/tooltip/tooltip_shortcut.test.tsx +++ b/webapp/platform/shared/src/components/tooltip/tooltip_shortcut.test.tsx @@ -69,4 +69,17 @@ describe('TooltipShortcut', () => { expect(screen.getByText('Enter')).toBeInTheDocument(); }); + + test('should render with tooltip variant styling', () => { + const shortcut = { + default: ['K'], + }; + + renderWithContext( + , + ); + + const keyElement = screen.getByText('K'); + expect(keyElement).toHaveClass('shortcut-key--tooltip'); + }); });