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) { >{message}
+{message}
+ {checkbox} +(modalData: ModalData
) => void;
};
};
@@ -107,11 +117,17 @@ export class SidebarList extends React.PureComponent