diff --git a/api4/channel.go b/api4/channel.go index 5fc8168544..23abf1dc85 100644 --- a/api4/channel.go +++ b/api4/channel.go @@ -1330,7 +1330,7 @@ func viewChannel(c *Context, w http.ResponseWriter, r *http.Request) { return } - times, err := c.App.ViewChannel(view, c.Params.UserId, c.AppContext.Session().Id) + times, err := c.App.ViewChannel(view, c.Params.UserId, c.AppContext.Session().Id, view.CollapsedThreadsSupported) if err != nil { c.Err = err return diff --git a/api4/channel_test.go b/api4/channel_test.go index 0503923373..85968de730 100644 --- a/api4/channel_test.go +++ b/api4/channel_test.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "net/http" + "os" "sort" "strings" "sync" @@ -4178,6 +4179,7 @@ func TestMoveChannel(t *testing.T) { func TestRootMentionsCount(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() + Client := th.Client user := th.BasicUser channel := th.BasicChannel @@ -4214,3 +4216,44 @@ func TestRootMentionsCount(t *testing.T) { require.Equal(t, int64(1), counts.MentionCountRoot) require.Equal(t, int64(2), counts.MentionCount) } + +func TestViewChannelWithoutCollapsedThreads(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + os.Setenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS") + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.ThreadAutoFollow = true + *cfg.ServiceSettings.CollapsedThreads = model.COLLAPSED_THREADS_DEFAULT_ON + }) + + Client := th.Client + user := th.BasicUser + team := th.BasicTeam + channel := th.BasicChannel + + // mention the user in a root post + post1, resp := th.SystemAdminClient.CreatePost(&model.Post{ChannelId: channel.Id, Message: "hey @" + user.Username}) + CheckNoError(t, resp) + // mention the user in a reply post + post2 := &model.Post{ChannelId: channel.Id, Message: "reply at @" + user.Username, RootId: post1.Id} + _, resp = th.SystemAdminClient.CreatePost(post2) + CheckNoError(t, resp) + + threads, resp := Client.GetUserThreads(user.Id, team.Id, model.GetUserThreadsOpts{}) + CheckNoError(t, resp) + require.EqualValues(t, int64(1), threads.TotalUnreadMentions) + + // simulate opening the channel from an old client + _, resp = Client.ViewChannel(user.Id, &model.ChannelView{ + ChannelId: channel.Id, + PrevChannelId: "", + CollapsedThreadsSupported: false, + }) + CheckNoError(t, resp) + + threads, resp = Client.GetUserThreads(user.Id, team.Id, model.GetUserThreadsOpts{}) + CheckNoError(t, resp) + require.Zero(t, threads.TotalUnreadMentions) +} diff --git a/api4/post.go b/api4/post.go index 305a7d9419..f52378082a 100644 --- a/api4/post.go +++ b/api4/post.go @@ -639,11 +639,15 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(patchedPost.ToJson())) } -func setPostUnread(c *Context, w http.ResponseWriter, _ *http.Request) { +func setPostUnread(c *Context, w http.ResponseWriter, r *http.Request) { c.RequirePostId().RequireUserId() if c.Err != nil { return } + + props := model.MapBoolFromJson(r.Body) + collapsedThreadsSupported := props["collapsed_threads_supported"] + if c.AppContext.Session().UserId != c.Params.UserId && !c.App.SessionHasPermissionToUser(*c.AppContext.Session(), c.Params.UserId) { c.SetPermissionError(model.PERMISSION_EDIT_OTHER_USERS) return @@ -653,7 +657,7 @@ func setPostUnread(c *Context, w http.ResponseWriter, _ *http.Request) { return } - state, err := c.App.MarkChannelAsUnreadFromPost(c.Params.PostId, c.Params.UserId) + state, err := c.App.MarkChannelAsUnreadFromPost(c.Params.PostId, c.Params.UserId, collapsedThreadsSupported) if err != nil { c.Err = err return diff --git a/api4/post_test.go b/api4/post_test.go index 6250aba0bb..9e6f6141b1 100644 --- a/api4/post_test.go +++ b/api4/post_test.go @@ -2493,14 +2493,14 @@ func TestSetChannelUnread(t *testing.T) { unread, err = th.App.GetChannelUnread(c1.Id, u2.Id) require.Nil(t, err) require.Equal(t, int64(4), unread.MsgCount) - _, err = th.App.ViewChannel(c1toc2, u2.Id, s2.Id) + _, err = th.App.ViewChannel(c1toc2, u2.Id, s2.Id, false) require.Nil(t, err) unread, err = th.App.GetChannelUnread(c1.Id, u2.Id) require.Nil(t, err) require.Equal(t, int64(0), unread.MsgCount) t.Run("Unread last one", func(t *testing.T) { - r := th.Client.SetPostUnread(u1.Id, p2.Id) + r := th.Client.SetPostUnread(u1.Id, p2.Id, true) checkHTTPStatus(t, r, 200, false) unread, err := th.App.GetChannelUnread(c1.Id, u1.Id) require.Nil(t, err) @@ -2508,12 +2508,12 @@ func TestSetChannelUnread(t *testing.T) { }) t.Run("Unread on a private channel", func(t *testing.T) { - r := th.Client.SetPostUnread(u1.Id, pp2.Id) + r := th.Client.SetPostUnread(u1.Id, pp2.Id, true) assert.Equal(t, 200, r.StatusCode) unread, err := th.App.GetChannelUnread(th.BasicPrivateChannel.Id, u1.Id) require.Nil(t, err) assert.Equal(t, int64(1), unread.MsgCount) - r = th.Client.SetPostUnread(u1.Id, pp1.Id) + r = th.Client.SetPostUnread(u1.Id, pp1.Id, true) assert.Equal(t, 200, r.StatusCode) unread, err = th.App.GetChannelUnread(th.BasicPrivateChannel.Id, u1.Id) require.Nil(t, err) @@ -2521,7 +2521,7 @@ func TestSetChannelUnread(t *testing.T) { }) t.Run("Can't unread an imaginary post", func(t *testing.T) { - r := th.Client.SetPostUnread(u1.Id, "invalid4ofngungryquinj976y") + r := th.Client.SetPostUnread(u1.Id, "invalid4ofngungryquinj976y", true) assert.Equal(t, http.StatusForbidden, r.StatusCode) }) @@ -2531,18 +2531,18 @@ func TestSetChannelUnread(t *testing.T) { c3.Login(u3.Email, u3.Password) t.Run("Can't unread channels you don't belong to", func(t *testing.T) { - r := c3.SetPostUnread(u3.Id, pp1.Id) + r := c3.SetPostUnread(u3.Id, pp1.Id, true) assert.Equal(t, http.StatusForbidden, r.StatusCode) }) t.Run("Can't unread users you don't have permission to edit", func(t *testing.T) { - r := c3.SetPostUnread(u1.Id, pp1.Id) + r := c3.SetPostUnread(u1.Id, pp1.Id, true) assert.Equal(t, http.StatusForbidden, r.StatusCode) }) t.Run("Can't unread if user is not logged in", func(t *testing.T) { th.Client.Logout() - response := th.Client.SetPostUnread(u1.Id, p2.Id) + response := th.Client.SetPostUnread(u1.Id, p2.Id, true) checkHTTPStatus(t, response, http.StatusUnauthorized, true) }) } @@ -2565,7 +2565,7 @@ func TestMarkUnreadCausesAutofollow(t *testing.T) { require.Nil(t, appErr) require.Zero(t, threads.Total) - _, appErr = th.App.MarkChannelAsUnreadFromPost(replyPost.Id, th.BasicUser.Id) + _, appErr = th.App.MarkChannelAsUnreadFromPost(replyPost.Id, th.BasicUser.Id, true) require.Nil(t, appErr) threads, appErr = th.App.GetThreadsForUser(th.BasicUser.Id, th.BasicTeam.Id, model.GetUserThreadsOpts{}) @@ -2573,3 +2573,70 @@ func TestMarkUnreadCausesAutofollow(t *testing.T) { require.NotZero(t, threads.Total) } + +func TestSetPostUnreadWithoutCollapsedThreads(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_COLLAPSEDTHREADS") + th := Setup(t).InitBasic() + defer th.TearDown() + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.ThreadAutoFollow = true + *cfg.ServiceSettings.CollapsedThreads = model.COLLAPSED_THREADS_DEFAULT_ON + }) + + // user2: first root mention @user1 + // - user1: hello + // - user2: mention @u1 + // - user1: another repoy + // - user2: another mention @u1 + // user1: a root post + // user2: Another root mention @u1 + user1Mention := " @" + th.BasicUser.Username + rootPost1, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "first root mention" + user1Mention}, th.BasicChannel, false, false) + require.Nil(t, appErr) + _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "hello"}, th.BasicChannel, false, false) + require.Nil(t, appErr) + replyPost1, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "mention" + user1Mention}, th.BasicChannel, false, false) + require.Nil(t, appErr) + _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another reply"}, th.BasicChannel, false, false) + require.Nil(t, appErr) + _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another mention" + user1Mention}, th.BasicChannel, false, false) + require.Nil(t, appErr) + _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "a root post"}, th.BasicChannel, false, false) + require.Nil(t, appErr) + _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another root mention" + user1Mention}, th.BasicChannel, false, false) + require.Nil(t, appErr) + + t.Run("Mark reply post as unread", func(t *testing.T) { + resp := th.Client.SetPostUnread(th.BasicUser.Id, replyPost1.Id, false) + CheckNoError(t, resp) + channelUnread, appErr := th.App.GetChannelUnread(th.BasicChannel.Id, th.BasicUser.Id) + require.Nil(t, appErr) + + require.Equal(t, int64(3), channelUnread.MentionCount) + // MentionCountRoot should be zero so that supported clients don't show a mention badge for the channel + require.Equal(t, int64(0), channelUnread.MentionCountRoot) + + require.Equal(t, int64(5), channelUnread.MsgCount) + // MentionCountRoot should be zero so that supported clients don't show the channel as unread + require.Equal(t, channelUnread.MsgCountRoot, int64(0)) + + thread, err := th.App.GetThreadForUser(th.BasicUser.Id, th.BasicTeam.Id, rootPost1.Id, false) + require.Nil(t, err) + require.Equal(t, int64(2), thread.UnreadMentions) + require.Equal(t, int64(3), thread.UnreadReplies) + }) + + t.Run("Mark root post as unread", func(t *testing.T) { + resp := th.Client.SetPostUnread(th.BasicUser.Id, rootPost1.Id, false) + CheckNoError(t, resp) + channelUnread, appErr := th.App.GetChannelUnread(th.BasicChannel.Id, th.BasicUser.Id) + require.Nil(t, appErr) + + require.Equal(t, int64(4), channelUnread.MentionCount) + require.Equal(t, int64(2), channelUnread.MentionCountRoot) + + require.Equal(t, int64(7), channelUnread.MsgCount) + require.Equal(t, int64(3), channelUnread.MsgCountRoot) + }) +} diff --git a/app/app_iface.go b/app/app_iface.go index 054a90f058..14eb05c897 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -241,7 +241,7 @@ type AppIface interface { // MakeAuditRecord creates a audit record pre-populated with defaults. MakeAuditRecord(event string, initialStatus string) *audit.Record // MarkChanelAsUnreadFromPost will take a post and set the channel as unread from that one. - MarkChannelAsUnreadFromPost(postID string, userID string) (*model.ChannelUnreadAt, *model.AppError) + MarkChannelAsUnreadFromPost(postID string, userID string, collapsedThreadsSupported bool) (*model.ChannelUnreadAt, *model.AppError) // MentionsToPublicChannels returns all the mentions to public channels, // linking them to their channels MentionsToPublicChannels(message, teamID string) model.ChannelMentionMap @@ -841,7 +841,7 @@ type AppIface interface { Log() *mlog.Logger LoginByOAuth(c *request.Context, service string, userData io.Reader, teamID string, tokenUser *model.User) (*model.User, *model.AppError) MakePermissionError(s *model.Session, permissions []*model.Permission) *model.AppError - MarkChannelsAsViewed(channelIDs []string, userID string, currentSessionId string) (map[string]int64, *model.AppError) + MarkChannelsAsViewed(channelIDs []string, userID string, currentSessionId string, collapsedThreadsSupported bool) (map[string]int64, *model.AppError) MaxPostSize() int MessageExport() einterfaces.MessageExportInterface Metrics() einterfaces.MetricsInterface @@ -1086,6 +1086,6 @@ type AppIface interface { UserCanSeeOtherUser(userID string, otherUserId string) (bool, *model.AppError) VerifyEmailFromToken(userSuppliedTokenString string) *model.AppError VerifyUserEmail(userID, email string) *model.AppError - ViewChannel(view *model.ChannelView, userID string, currentSessionId string) (map[string]int64, *model.AppError) + ViewChannel(view *model.ChannelView, userID string, currentSessionId string, collapsedThreadsSupported bool) (map[string]int64, *model.AppError) WriteFile(fr io.Reader, path string) (int64, *model.AppError) } diff --git a/app/channel.go b/app/channel.go index 57c6612879..7908b8127a 100644 --- a/app/channel.go +++ b/app/channel.go @@ -2378,7 +2378,10 @@ func (a *App) UpdateChannelLastViewedAt(channelIDs []string, userID string) *mod } // MarkChanelAsUnreadFromPost will take a post and set the channel as unread from that one. -func (a *App) MarkChannelAsUnreadFromPost(postID string, userID string) (*model.ChannelUnreadAt, *model.AppError) { +func (a *App) MarkChannelAsUnreadFromPost(postID string, userID string, collapsedThreadsSupported bool) (*model.ChannelUnreadAt, *model.AppError) { + if !collapsedThreadsSupported { + return a.markChannelAsUnreadFromPostCRTUnsupported(postID, userID) + } post, err := a.GetSinglePost(postID) if err != nil { return nil, err @@ -2394,7 +2397,9 @@ func (a *App) MarkChannelAsUnreadFromPost(postID string, userID string) (*model. return nil, err } - if *a.Config().ServiceSettings.ThreadAutoFollow { + // if auto-follow is on + // if threadmembership does not exists we create one and update + if *a.Config().ServiceSettings.ThreadAutoFollow && collapsedThreadsSupported { threadId := post.RootId if post.RootId == "" { threadId = post.Id @@ -2434,25 +2439,127 @@ func (a *App) MarkChannelAsUnreadFromPost(postID string, userID string) (*model. } } } - } - channelUnread, nErr := a.Srv().Store.Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, unreadMentionsRoot, *a.Config().ServiceSettings.ThreadAutoFollow) + channelUnread, nErr := a.Srv().Store.Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, unreadMentionsRoot, *a.Config().ServiceSettings.ThreadAutoFollow, true) if nErr != nil { return channelUnread, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, nErr.Error(), http.StatusInternalServerError) } + a.sendWebSocketPostUnreadEvent(channelUnread, postID, false) + a.UpdateMobileAppBadge(userID) + + return channelUnread, nil +} + +func (a *App) markChannelAsUnreadFromPostCRTUnsupported(postID string, userID string) (*model.ChannelUnreadAt, *model.AppError) { + post, err := a.GetSinglePost(postID) + if err != nil { + return nil, err + } + + user, err := a.GetUser(userID) + if err != nil { + return nil, err + } + + threadId := post.RootId + if post.RootId == "" { + threadId = post.Id + } + + unreadMentions, unreadMentionsRoot, err := a.countMentionsFromPost(user, post) + if err != nil { + return nil, err + } + + // if root post, + // In CRT Supported Client: badge on channel only sums mentions in root posts including and below the post that was marked. + // In CRT Unsupported Client: badge on channel sums mentions in all posts (root & replies) including and below the post that was marked unread. + if post.RootId == "" { + channelUnread, nErr := a.Srv().Store.Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, unreadMentionsRoot, false, true) + if nErr != nil { + return channelUnread, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + a.sendWebSocketPostUnreadEvent(channelUnread, postID, true) + a.UpdateMobileAppBadge(userID) + return channelUnread, nil + } + + // if reply post, autofollow thread and + // In CRT Supported Client: Mark the specific thread as unread but not the channel where the thread exists. + // If there are replies with mentions below the marked reply in the thread, then sum the mentions for the threads mention badge. + // In CRT Unsupported Client: Channel is marked as unread and new messages line inserted above the marked post. + // Badge on channel sums mentions in all posts (root & replies) including and below the post that was marked unread. + rootPost, err := a.GetSinglePost(post.RootId) + if err != nil { + return nil, err + } + + channel, nErr := a.Srv().Store.Channel().Get(post.ChannelId, true) + if nErr != nil { + return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + + threadMembership, nErr := a.Srv().Store.Thread().GetMembershipForUser(user.Id, threadId) + var errNotFound *store.ErrNotFound + if nErr != nil && !errors.As(nErr, &errNotFound) { + return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + // Follow thread if we're not already following it + if threadMembership == nil { + threadMembership, nErr = a.Srv().Store.Thread().MaintainMembership(user.Id, threadId, true, false, true, false, false) + if nErr != nil { + return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + } + // If threadmembership already exists but user had previously unfollowed the thread, then follow the thread again. + threadMembership.Following = true + threadMembership.LastViewed = post.UpdateAt - 1 + threadMembership.UnreadMentions, err = a.countThreadMentions(user, rootPost, channel.TeamId, post.UpdateAt-1) + if err != nil { + return nil, err + } + _, nErr = a.Srv().Store.Thread().UpdateMembership(threadMembership) + if nErr != nil { + return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + thread, nErr := a.Srv().Store.Thread().GetThreadForUser(userID, channel.TeamId, threadId, true) + if nErr != nil { + return nil, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + a.sanitizeProfiles(thread.Participants, false) + thread.Post.SanitizeProps() + + payload := thread.ToJson() + sendEvent := *a.Config().ServiceSettings.CollapsedThreads == model.COLLAPSED_THREADS_DEFAULT_ON + if preference, err := a.Srv().Store.Preference().Get(userID, model.PREFERENCE_CATEGORY_DISPLAY_SETTINGS, model.PREFERENCE_NAME_COLLAPSED_THREADS_ENABLED); err == nil { + sendEvent = preference.Value == "on" + } + if sendEvent { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_THREAD_UPDATED, channel.TeamId, "", userID, nil) + message.Add("thread", payload) + a.Publish(message) + } + channelUnread, nErr := a.Srv().Store.Channel().UpdateLastViewedAtPost(post, userID, unreadMentions, 0, false, false) + if nErr != nil { + return channelUnread, model.NewAppError("MarkChannelAsUnreadFromPost", "app.channel.update_last_viewed_at_post.app_error", nil, nErr.Error(), http.StatusInternalServerError) + } + return channelUnread, nil +} + +func (a *App) sendWebSocketPostUnreadEvent(channelUnread *model.ChannelUnreadAt, postID string, withMsgCountRoot bool) { message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_POST_UNREAD, channelUnread.TeamId, channelUnread.ChannelId, channelUnread.UserId, nil) message.Add("msg_count", channelUnread.MsgCount) + if withMsgCountRoot { + message.Add("msg_count_root", channelUnread.MsgCountRoot) + } message.Add("mention_count", channelUnread.MentionCount) message.Add("mention_count_root", channelUnread.MentionCountRoot) message.Add("last_viewed_at", channelUnread.LastViewedAt) message.Add("post_id", postID) a.Publish(message) - - a.UpdateMobileAppBadge(userID) - - return channelUnread, nil } func (a *App) AutocompleteChannels(teamID string, term string) (*model.ChannelList, *model.AppError) { @@ -2571,7 +2678,7 @@ func (a *App) SearchChannelsUserNotIn(teamID string, userID string, term string) return channelList, nil } -func (a *App) MarkChannelsAsViewed(channelIDs []string, userID string, currentSessionId string) (map[string]int64, *model.AppError) { +func (a *App) MarkChannelsAsViewed(channelIDs []string, userID string, currentSessionId string, collapsedThreadsSupported bool) (map[string]int64, *model.AppError) { // I start looking for channels with notifications before I mark it as read, to clear the push notifications if needed channelsToClearPushNotifications := []string{} if *a.Config().EmailSettings.SendPushNotifications { @@ -2633,10 +2740,32 @@ func (a *App) MarkChannelsAsViewed(channelIDs []string, userID string, currentSe for _, channelID := range channelsToClearPushNotifications { a.clearPushNotification(currentSessionId, userID, channelID) } + + if !collapsedThreadsSupported { + // for compatibility with old clients, when channel is viewed - mark all threads in that channel as read + threadsEnabled := *a.Config().ServiceSettings.CollapsedThreads == model.COLLAPSED_THREADS_DEFAULT_ON + // check if a participant has overridden collapsed threads settings + if preference, err := a.Srv().Store.Preference().Get(userID, model.PREFERENCE_CATEGORY_DISPLAY_SETTINGS, model.PREFERENCE_NAME_COLLAPSED_THREADS_ENABLED); err == nil { + threadsEnabled = preference.Value == "on" + } + if threadsEnabled { + if err := a.Srv().Store.Thread().MarkAllAsReadInChannels(userID, channelIDs); err != nil { + return nil, model.NewAppError("MarkChannelsAsViewed", "app.channel.update_last_viewed_at.app_error", nil, err.Error(), http.StatusInternalServerError) + } + + timestamp := model.GetMillis() + for _, channelID := range channelIDs { + message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_THREAD_READ_CHANGED, "", channelID, userID, nil) + message.Add("timestamp", timestamp) + a.Publish(message) + } + } + } + return times, nil } -func (a *App) ViewChannel(view *model.ChannelView, userID string, currentSessionId string) (map[string]int64, *model.AppError) { +func (a *App) ViewChannel(view *model.ChannelView, userID string, currentSessionId string, collapsedThreadsSupported bool) (map[string]int64, *model.AppError) { if err := a.SetActiveChannel(userID, view.ChannelId); err != nil { return nil, err } @@ -2655,7 +2784,7 @@ func (a *App) ViewChannel(view *model.ChannelView, userID string, currentSession return map[string]int64{}, nil } - return a.MarkChannelsAsViewed(channelIDs, userID, currentSessionId) + return a.MarkChannelsAsViewed(channelIDs, userID, currentSessionId, collapsedThreadsSupported) } func (a *App) PermanentDeleteChannel(channel *model.Channel) *model.AppError { diff --git a/app/channel_test.go b/app/channel_test.go index 4b7e8d25f5..6cd0107bfd 100644 --- a/app/channel_test.go +++ b/app/channel_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost-server/v5/model" @@ -1270,7 +1271,7 @@ func TestMarkChannelAsUnreadFromPost(t *testing.T) { require.Equal(t, int64(0), unread.MsgCount) t.Run("Unread but last one", func(t *testing.T) { - response, err := th.App.MarkChannelAsUnreadFromPost(p2.Id, u1.Id) + response, err := th.App.MarkChannelAsUnreadFromPost(p2.Id, u1.Id, true) require.Nil(t, err) require.NotNil(t, response) assert.Equal(t, int64(2), response.MsgCount) @@ -1281,7 +1282,7 @@ func TestMarkChannelAsUnreadFromPost(t *testing.T) { }) t.Run("Unread last one", func(t *testing.T) { - response, err := th.App.MarkChannelAsUnreadFromPost(p3.Id, u1.Id) + response, err := th.App.MarkChannelAsUnreadFromPost(p3.Id, u1.Id, true) require.Nil(t, err) require.NotNil(t, response) assert.Equal(t, int64(3), response.MsgCount) @@ -1292,7 +1293,7 @@ func TestMarkChannelAsUnreadFromPost(t *testing.T) { }) t.Run("Unread first one", func(t *testing.T) { - response, err := th.App.MarkChannelAsUnreadFromPost(p1.Id, u1.Id) + response, err := th.App.MarkChannelAsUnreadFromPost(p1.Id, u1.Id, true) require.Nil(t, err) require.NotNil(t, response) assert.Equal(t, int64(1), response.MsgCount) @@ -1309,7 +1310,7 @@ func TestMarkChannelAsUnreadFromPost(t *testing.T) { }) t.Run("Unread on a private channel", func(t *testing.T) { - response, err := th.App.MarkChannelAsUnreadFromPost(pp1.Id, u1.Id) + response, err := th.App.MarkChannelAsUnreadFromPost(pp1.Id, u1.Id, true) require.Nil(t, err) require.NotNil(t, response) assert.Equal(t, int64(0), response.MsgCount) @@ -1318,7 +1319,7 @@ func TestMarkChannelAsUnreadFromPost(t *testing.T) { assert.Equal(t, int64(2), unread.MsgCount) assert.Equal(t, pp1.CreateAt-1, response.LastViewedAt) - response, err = th.App.MarkChannelAsUnreadFromPost(pp2.Id, u1.Id) + response, err = th.App.MarkChannelAsUnreadFromPost(pp2.Id, u1.Id, true) assert.Nil(t, err) assert.Equal(t, int64(1), response.MsgCount) unread, err = th.App.GetChannelUnread(pc1.Id, u1.Id) @@ -1347,7 +1348,7 @@ func TestMarkChannelAsUnreadFromPost(t *testing.T) { Message: "@" + u1.Username, }, c2, false, true) - response, err := th.App.MarkChannelAsUnreadFromPost(p4.Id, u1.Id) + response, err := th.App.MarkChannelAsUnreadFromPost(p4.Id, u1.Id, true) assert.Nil(t, err) assert.Equal(t, int64(1), response.MsgCount) assert.Equal(t, int64(2), response.MentionCount) @@ -1370,7 +1371,7 @@ func TestMarkChannelAsUnreadFromPost(t *testing.T) { _, err := th.App.CreatePost(th.Context, &model.Post{ChannelId: dc.Id, UserId: th.BasicUser.Id, Message: "testReply", RootId: dm1.Id}, dc, false, false) assert.Nil(t, err) - response, err := th.App.MarkChannelAsUnreadFromPost(dm1.Id, u2.Id) + response, err := th.App.MarkChannelAsUnreadFromPost(dm1.Id, u2.Id, true) assert.Nil(t, err) assert.Equal(t, int64(0), response.MsgCount) assert.Equal(t, int64(4), response.MentionCount) @@ -1384,7 +1385,7 @@ func TestMarkChannelAsUnreadFromPost(t *testing.T) { }) t.Run("Can't unread an imaginary post", func(t *testing.T) { - response, err := th.App.MarkChannelAsUnreadFromPost("invalid4ofngungryquinj976y", u1.Id) + response, err := th.App.MarkChannelAsUnreadFromPost("invalid4ofngungryquinj976y", u1.Id, true) assert.NotNil(t, err) assert.Nil(t, response) }) @@ -1966,10 +1967,13 @@ func TestMarkChannelsAsViewedPanic(t *testing.T) { "userID": 1, } mockChannelStore.On("UpdateLastViewedAt", []string{"channelID"}, "userID", false).Return(times, nil) + mockPreferenceStore := mocks.PreferenceStore{} + mockPreferenceStore.On("Get", mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(&model.Preference{Value: "test"}, nil) mockStore.On("User").Return(&mockUserStore) mockStore.On("Channel").Return(&mockChannelStore) + mockStore.On("Preference").Return(&mockPreferenceStore) - _, err := th.App.MarkChannelsAsViewed([]string{"channelID"}, "userID", th.Context.Session().Id) + _, err := th.App.MarkChannelsAsViewed([]string{"channelID"}, "userID", th.Context.Session().Id, false) require.Nil(t, err) } diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 2f1d45946f..51558bdcbb 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -11221,7 +11221,7 @@ func (a *OpenTracingAppLayer) MakePermissionError(s *model.Session, permissions return resultVar0 } -func (a *OpenTracingAppLayer) MarkChannelAsUnreadFromPost(postID string, userID string) (*model.ChannelUnreadAt, *model.AppError) { +func (a *OpenTracingAppLayer) MarkChannelAsUnreadFromPost(postID string, userID string, collapsedThreadsSupported bool) (*model.ChannelUnreadAt, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.MarkChannelAsUnreadFromPost") @@ -11233,7 +11233,7 @@ func (a *OpenTracingAppLayer) MarkChannelAsUnreadFromPost(postID string, userID }() defer span.Finish() - resultVar0, resultVar1 := a.app.MarkChannelAsUnreadFromPost(postID, userID) + resultVar0, resultVar1 := a.app.MarkChannelAsUnreadFromPost(postID, userID, collapsedThreadsSupported) if resultVar1 != nil { span.LogFields(spanlog.Error(resultVar1)) @@ -11243,7 +11243,7 @@ func (a *OpenTracingAppLayer) MarkChannelAsUnreadFromPost(postID string, userID return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) MarkChannelsAsViewed(channelIDs []string, userID string, currentSessionId string) (map[string]int64, *model.AppError) { +func (a *OpenTracingAppLayer) MarkChannelsAsViewed(channelIDs []string, userID string, currentSessionId string, collapsedThreadsSupported bool) (map[string]int64, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.MarkChannelsAsViewed") @@ -11255,7 +11255,7 @@ func (a *OpenTracingAppLayer) MarkChannelsAsViewed(channelIDs []string, userID s }() defer span.Finish() - resultVar0, resultVar1 := a.app.MarkChannelsAsViewed(channelIDs, userID, currentSessionId) + resultVar0, resultVar1 := a.app.MarkChannelsAsViewed(channelIDs, userID, currentSessionId, collapsedThreadsSupported) if resultVar1 != nil { span.LogFields(spanlog.Error(resultVar1)) @@ -17079,7 +17079,7 @@ func (a *OpenTracingAppLayer) VerifyUserEmail(userID string, email string) *mode return resultVar0 } -func (a *OpenTracingAppLayer) ViewChannel(view *model.ChannelView, userID string, currentSessionId string) (map[string]int64, *model.AppError) { +func (a *OpenTracingAppLayer) ViewChannel(view *model.ChannelView, userID string, currentSessionId string, collapsedThreadsSupported bool) (map[string]int64, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ViewChannel") @@ -17091,7 +17091,7 @@ func (a *OpenTracingAppLayer) ViewChannel(view *model.ChannelView, userID string }() defer span.Finish() - resultVar0, resultVar1 := a.app.ViewChannel(view, userID, currentSessionId) + resultVar0, resultVar1 := a.app.ViewChannel(view, userID, currentSessionId, collapsedThreadsSupported) if resultVar1 != nil { span.LogFields(spanlog.Error(resultVar1)) diff --git a/app/post.go b/app/post.go index 3c6eef5e69..cfcf2f05d3 100644 --- a/app/post.go +++ b/app/post.go @@ -88,7 +88,7 @@ func (a *App) CreatePostAsUser(c *request.Context, post *model.Post, currentSess _, fromWebhook := post.GetProps()["from_webhook"] _, fromBot := post.GetProps()["from_bot"] if !fromWebhook && !fromBot { - if _, err := a.MarkChannelsAsViewed([]string{post.ChannelId}, post.UserId, currentSessionId); err != nil { + if _, err := a.MarkChannelsAsViewed([]string{post.ChannelId}, post.UserId, currentSessionId, true); err != nil { mlog.Warn( "Encountered error updating last viewed", mlog.String("channel_id", post.ChannelId), diff --git a/app/post_test.go b/app/post_test.go index 4105f6be12..9bdc17d162 100644 --- a/app/post_test.go +++ b/app/post_test.go @@ -2032,7 +2032,7 @@ func TestViewChannelShouldNotUpdateThreads(t *testing.T) { th.App.ViewChannel(&model.ChannelView{ ChannelId: channel.Id, PrevChannelId: "", - }, user2.Id, "") + }, user2.Id, "", true) m1, e1 := th.App.GetThreadMembershipsForUser(user2.Id, th.BasicTeam.Id) require.NoError(t, e1) @@ -2072,7 +2072,7 @@ func TestCollapsedThreadFetch(t *testing.T) { thread, nErr := th.App.Srv().Store.Thread().Get(postRoot.Id) require.NoError(t, nErr) require.Len(t, thread.Participants, 1) - th.App.MarkChannelAsUnreadFromPost(postRoot.Id, user1.Id) + th.App.MarkChannelAsUnreadFromPost(postRoot.Id, user1.Id, true) l, err := th.App.GetPostsForChannelAroundLastUnread(channel.Id, user1.Id, 10, 10, true, true, false) require.Nil(t, err) require.Len(t, l.Order, 1) diff --git a/model/channel_view.go b/model/channel_view.go index 42fcac3a36..2ba74434e5 100644 --- a/model/channel_view.go +++ b/model/channel_view.go @@ -9,8 +9,9 @@ import ( ) type ChannelView struct { - ChannelId string `json:"channel_id"` - PrevChannelId string `json:"prev_channel_id"` + ChannelId string `json:"channel_id"` + PrevChannelId string `json:"prev_channel_id"` + CollapsedThreadsSupported bool `json:"collapsed_threads_supported"` } func (o *ChannelView) ToJson() string { diff --git a/model/client4.go b/model/client4.go index f49ca3c3e2..75738d1394 100644 --- a/model/client4.go +++ b/model/client4.go @@ -2915,8 +2915,9 @@ func (c *Client4) PatchPost(postId string, patch *PostPatch) (*Post, *Response) } // SetPostUnread marks channel where post belongs as unread on the time of the provided post. -func (c *Client4) SetPostUnread(userId string, postId string) *Response { - r, err := c.DoApiPost(c.GetUserRoute(userId)+c.GetPostRoute(postId)+"/set_unread", "") +func (c *Client4) SetPostUnread(userId string, postId string, collapsedThreadsSupported bool) *Response { + b, _ := json.Marshal(map[string]bool{"collapsed_threads_supported": collapsedThreadsSupported}) + r, err := c.DoApiPost(c.GetUserRoute(userId)+c.GetPostRoute(postId)+"/set_unread", string(b)) if err != nil { return BuildErrorResponse(r, err) } diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index fd94fc63ff..2ef44c99b8 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -2176,7 +2176,7 @@ func (s *OpenTracingLayerChannelStore) UpdateLastViewedAt(channelIds []string, u return result, err } -func (s *OpenTracingLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, updateThreads bool) (*model.ChannelUnreadAt, error) { +func (s *OpenTracingLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, updateThreads bool, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ChannelStore.UpdateLastViewedAtPost") s.Root.Store.SetContext(newCtx) @@ -2185,7 +2185,7 @@ func (s *OpenTracingLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model. }() defer span.Finish() - result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads) + result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads, setUnreadCountRoot) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) @@ -8938,6 +8938,24 @@ func (s *OpenTracingLayerThreadStore) MarkAllAsRead(userID string, teamID string return err } +func (s *OpenTracingLayerThreadStore) MarkAllAsReadInChannels(userID string, channelIDs []string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.MarkAllAsReadInChannels") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.ThreadStore.MarkAllAsReadInChannels(userID, channelIDs) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + func (s *OpenTracingLayerThreadStore) MarkAsRead(userID string, threadID string, timestamp int64) error { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.MarkAsRead") diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index dbef70d4ac..aeb32fe74e 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -2308,11 +2308,11 @@ func (s *RetryLayerChannelStore) UpdateLastViewedAt(channelIds []string, userID } -func (s *RetryLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, updateThreads bool) (*model.ChannelUnreadAt, error) { +func (s *RetryLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, updateThreads bool, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { tries := 0 for { - result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads) + result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads, setUnreadCountRoot) if err == nil { return result, nil } @@ -9728,6 +9728,26 @@ func (s *RetryLayerThreadStore) MarkAllAsRead(userID string, teamID string) erro } +func (s *RetryLayerThreadStore) MarkAllAsReadInChannels(userID string, channelIDs []string) error { + + tries := 0 + for { + err := s.ThreadStore.MarkAllAsReadInChannels(userID, channelIDs) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + } + +} + func (s *RetryLayerThreadStore) MarkAsRead(userID string, threadID string, timestamp int64) error { tries := 0 diff --git a/store/sqlstore/channel_store.go b/store/sqlstore/channel_store.go index 988a8151c3..85583682f0 100644 --- a/store/sqlstore/channel_store.go +++ b/store/sqlstore/channel_store.go @@ -2216,7 +2216,7 @@ func (s SqlChannelStore) CountPostsAfter(channelId string, timestamp int64, user // UpdateLastViewedAtPost updates a ChannelMember as if the user last read the channel at the time of the given post. // If the provided mentionCount is -1, the given post and all posts after it are considered to be mentions. Returns // an updated model.ChannelUnreadAt that can be returned to the client. -func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot int, updateThreads bool) (*model.ChannelUnreadAt, error) { +func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot int, updateThreads bool, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { var threadsToUpdate []string unreadDate := unreadPost.CreateAt - 1 if updateThreads { @@ -2232,6 +2232,10 @@ func (s SqlChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID s return nil, err } + if !setUnreadCountRoot { + unreadRoot = 0 + } + params := map[string]interface{}{ "mentions": mentionCount, "mentionsRoot": mentionCountRoot, diff --git a/store/sqlstore/thread_store.go b/store/sqlstore/thread_store.go index 84d2266fe0..41fb383020 100644 --- a/store/sqlstore/thread_store.go +++ b/store/sqlstore/thread_store.go @@ -386,7 +386,37 @@ func (s *SqlThreadStore) GetThreadForUser(userId, teamId, threadId string, exten return result, nil } +func (s *SqlThreadStore) MarkAllAsReadInChannels(userID string, channelIDs []string) error { + var threadIDs []string + query, args, _ := s.getQueryBuilder(). + Select("ThreadMemberships.PostId"). + Join("Threads ON Threads.PostId = ThreadMemberships.PostId"). + Join("Channels ON Threads.ChannelId = Channels.Id"). + From("ThreadMemberships"). + Where(sq.Eq{"Threads.ChannelId": channelIDs}). + Where(sq.Eq{"ThreadMemberships.UserId": userID}). + ToSql() + + _, err := s.GetReplica().Select(&threadIDs, query, args...) + if err != nil { + return errors.Wrapf(err, "failed to get thread membership with userid=%s", userID) + } + + timestamp := model.GetMillis() + query, args, _ = s.getQueryBuilder(). + Update("ThreadMemberships"). + Where(sq.Eq{"PostId": threadIDs}). + Where(sq.Eq{"UserId": userID}). + Set("LastViewed", timestamp). + Set("UnreadMentions", 0). + ToSql() + if _, err := s.GetMaster().Exec(query, args...); err != nil { + return errors.Wrapf(err, "failed to update thread read state for user id=%s", userID) + } + return nil + +} func (s *SqlThreadStore) MarkAllAsRead(userId, teamId string) error { memberships, err := s.GetMembershipsForUser(userId, teamId) if err != nil { diff --git a/store/store.go b/store/store.go index 21c5dae0a5..c6193451c5 100644 --- a/store/store.go +++ b/store/store.go @@ -216,7 +216,7 @@ type ChannelStore interface { PermanentDeleteMembersByUser(userID string) error PermanentDeleteMembersByChannel(channelID string) error UpdateLastViewedAt(channelIds []string, userID string, updateThreads bool) (map[string]int64, error) - UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot int, updateThreads bool) (*model.ChannelUnreadAt, error) + UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount, mentionCountRoot int, updateThreads bool, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) CountPostsAfter(channelID string, timestamp int64, userID string) (int, int, error) IncrementMentionCount(channelID string, userID string, updateThreads, isRoot bool) error AnalyticsTypeCount(teamID string, channelType string) (int64, error) @@ -288,6 +288,7 @@ type ThreadStore interface { GetPosts(threadID string, since int64) ([]*model.Post, error) MarkAllAsRead(userID, teamID string) error + MarkAllAsReadInChannels(userID string, channelIDs []string) error MarkAsRead(userID, threadID string, timestamp int64) error SaveMembership(membership *model.ThreadMembership) (*model.ThreadMembership, error) diff --git a/store/storetest/mocks/ChannelStore.go b/store/storetest/mocks/ChannelStore.go index 72d600179f..d5c28df42e 100644 --- a/store/storetest/mocks/ChannelStore.go +++ b/store/storetest/mocks/ChannelStore.go @@ -1861,13 +1861,13 @@ func (_m *ChannelStore) UpdateLastViewedAt(channelIds []string, userID string, u return r0, r1 } -// UpdateLastViewedAtPost provides a mock function with given fields: unreadPost, userID, mentionCount, mentionCountRoot, updateThreads -func (_m *ChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, updateThreads bool) (*model.ChannelUnreadAt, error) { - ret := _m.Called(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads) +// UpdateLastViewedAtPost provides a mock function with given fields: unreadPost, userID, mentionCount, mentionCountRoot, updateThreads, setUnreadCountRoot +func (_m *ChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, updateThreads bool, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { + ret := _m.Called(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads, setUnreadCountRoot) var r0 *model.ChannelUnreadAt - if rf, ok := ret.Get(0).(func(*model.Post, string, int, int, bool) *model.ChannelUnreadAt); ok { - r0 = rf(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads) + if rf, ok := ret.Get(0).(func(*model.Post, string, int, int, bool, bool) *model.ChannelUnreadAt); ok { + r0 = rf(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads, setUnreadCountRoot) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.ChannelUnreadAt) @@ -1875,8 +1875,8 @@ func (_m *ChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID st } var r1 error - if rf, ok := ret.Get(1).(func(*model.Post, string, int, int, bool) error); ok { - r1 = rf(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads) + if rf, ok := ret.Get(1).(func(*model.Post, string, int, int, bool, bool) error); ok { + r1 = rf(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads, setUnreadCountRoot) } else { r1 = ret.Error(1) } diff --git a/store/storetest/mocks/ThreadStore.go b/store/storetest/mocks/ThreadStore.go index 36b5d654b5..345602ddee 100644 --- a/store/storetest/mocks/ThreadStore.go +++ b/store/storetest/mocks/ThreadStore.go @@ -263,6 +263,20 @@ func (_m *ThreadStore) MarkAllAsRead(userID string, teamID string) error { return r0 } +// MarkAllAsReadInChannels provides a mock function with given fields: userID, channelIDs +func (_m *ThreadStore) MarkAllAsReadInChannels(userID string, channelIDs []string) error { + ret := _m.Called(userID, channelIDs) + + var r0 error + if rf, ok := ret.Get(0).(func(string, []string) error); ok { + r0 = rf(userID, channelIDs) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // MarkAsRead provides a mock function with given fields: userID, threadID, timestamp func (_m *ThreadStore) MarkAsRead(userID string, threadID string, timestamp int64) error { ret := _m.Called(userID, threadID, timestamp) diff --git a/store/storetest/thread_store.go b/store/storetest/thread_store.go index f501440621..43bab5ba24 100644 --- a/store/storetest/thread_store.go +++ b/store/storetest/thread_store.go @@ -243,7 +243,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { _, err := ss.Thread().UpdateMembership(m) require.NoError(t, err) - _, err = ss.Channel().UpdateLastViewedAtPost(newPosts[0], newPosts[0].UserId, 0, 0, true) + _, err = ss.Channel().UpdateLastViewedAtPost(newPosts[0], newPosts[0].UserId, 0, 0, true, true) require.NoError(t, err) assert.Eventually(t, func() bool { @@ -325,7 +325,7 @@ func testThreadStorePopulation(t *testing.T, ss store.Store) { _, err := ss.Thread().UpdateMembership(m) require.NoError(t, err) - _, err = ss.Channel().UpdateLastViewedAtPost(newPosts[0], newPosts[0].UserId, 0, 0, true) + _, err = ss.Channel().UpdateLastViewedAtPost(newPosts[0], newPosts[0].UserId, 0, 0, true, true) require.NoError(t, err) assert.Eventually(t, func() bool { diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index b53f1792af..1abca40c11 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -2014,10 +2014,10 @@ func (s *TimerLayerChannelStore) UpdateLastViewedAt(channelIds []string, userID return result, err } -func (s *TimerLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, updateThreads bool) (*model.ChannelUnreadAt, error) { +func (s *TimerLayerChannelStore) UpdateLastViewedAtPost(unreadPost *model.Post, userID string, mentionCount int, mentionCountRoot int, updateThreads bool, setUnreadCountRoot bool) (*model.ChannelUnreadAt, error) { start := timemodule.Now() - result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads) + result, err := s.ChannelStore.UpdateLastViewedAtPost(unreadPost, userID, mentionCount, mentionCountRoot, updateThreads, setUnreadCountRoot) elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) if s.Root.Metrics != nil { @@ -8054,6 +8054,22 @@ func (s *TimerLayerThreadStore) MarkAllAsRead(userID string, teamID string) erro return err } +func (s *TimerLayerThreadStore) MarkAllAsReadInChannels(userID string, channelIDs []string) error { + start := timemodule.Now() + + err := s.ThreadStore.MarkAllAsReadInChannels(userID, channelIDs) + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.MarkAllAsReadInChannels", success, elapsed) + } + return err +} + func (s *TimerLayerThreadStore) MarkAsRead(userID string, threadID string, timestamp int64) error { start := timemodule.Now()