diff --git a/.gitignore b/.gitignore index e483fb16e9..d2feb985d3 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,5 @@ docker-compose.override.yaml .notice-work/ .aider* .env + +CLAUDE.md diff --git a/api/v4/source/posts.yaml b/api/v4/source/posts.yaml index 809c810382..4a8397535e 100644 --- a/api/v4/source/posts.yaml +++ b/api/v4/source/posts.yaml @@ -401,6 +401,12 @@ schema: type: integer default: 0 + - name: fromUpdateAt + in: query + description: The update_at timestamp to return the next page of posts from. You cannot set this flag with direction=down. + schema: + type: integer + default: 0 - name: direction in: query description: The direction to return the posts. Either up or down. @@ -425,6 +431,12 @@ schema: type: boolean default: false + - name: updatesOnly + in: query + description: This flag is used to make the API work with the updateAt value. If you set this flag, you must set a value for fromUpdateAt. + schema: + type: boolean + default: false responses: "200": description: Post list retrieval successful diff --git a/server/channels/api4/post.go b/server/channels/api4/post.go index f45e25da30..7c9b0ad3cb 100644 --- a/server/channels/api4/post.go +++ b/server/channels/api4/post.go @@ -671,6 +671,27 @@ func getPostThread(c *Context, w http.ResponseWriter, r *http.Request) { return } + var fromUpdateAt int64 + if fromUpdateAtStr := r.URL.Query().Get("fromUpdateAt"); fromUpdateAtStr != "" { + var err error + fromUpdateAt, err = strconv.ParseInt(fromUpdateAtStr, 10, 64) + if err != nil { + c.SetInvalidParamWithErr("fromUpdateAt", err) + return + } + } + + if fromUpdateAt != 0 && fromCreateAt != 0 { + c.SetInvalidParamWithDetails("fromUpdateAt", "both fromUpdateAt and fromCreateAt cannot be set") + return + } + + updatesOnly := r.URL.Query().Get("updatesOnly") == "true" + if updatesOnly && fromUpdateAt == 0 { + c.SetInvalidParamWithDetails("fromUpdateAt", "fromUpdateAt must be set if updatesOnly is set") + return + } + direction := "" if dir := r.URL.Query().Get("direction"); dir != "" { if dir != "up" && dir != "down" { @@ -679,14 +700,22 @@ func getPostThread(c *Context, w http.ResponseWriter, r *http.Request) { } direction = dir } + + if updatesOnly && direction == "up" { + c.SetInvalidParamWithDetails("updatesOnly", "updatesOnly flag cannot be used with up direction") + return + } + opts := model.GetPostsOptions{ SkipFetchThreads: r.URL.Query().Get("skipFetchThreads") == "true", CollapsedThreads: r.URL.Query().Get("collapsedThreads") == "true", CollapsedThreadsExtended: r.URL.Query().Get("collapsedThreadsExtended") == "true", + UpdatesOnly: updatesOnly, PerPage: perPage, Direction: direction, FromPost: fromPost, FromCreateAt: fromCreateAt, + FromUpdateAt: fromUpdateAt, } list, err := c.App.GetPostThread(c.Params.PostId, opts, c.AppContext.Session().UserId) if err != nil { diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go index 76facb727f..8825a92ae9 100644 --- a/server/channels/api4/post_test.go +++ b/server/channels/api4/post_test.go @@ -3638,7 +3638,60 @@ func TestGetPostThread(t *testing.T) { require.Error(t, err) CheckForbiddenStatus(t, resp) + // Test the new query parameters - updatesOnly, fromUpdateAt // Sending some bad params + _, resp, err = client.GetPostThreadWithOpts(context.Background(), th.BasicPost.Id, "", model.GetPostsOptions{ + UpdatesOnly: true, // updatesOnly is true but fromUpdateAt is not set + }) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + // Test error when both fromUpdateAt and fromCreateAt are set + _, resp, err = client.GetPostThreadWithOpts(context.Background(), th.BasicPost.Id, "", model.GetPostsOptions{ + FromUpdateAt: 12345, + FromCreateAt: 12345, + }) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + // Test error when updatesOnly is used with direction="up" + _, resp, err = client.GetPostThreadWithOpts(context.Background(), th.BasicPost.Id, "", model.GetPostsOptions{ + UpdatesOnly: true, + FromUpdateAt: 12345, + Direction: "up", + }) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + // Test valid parameters + // This should work with proper parameters + _, resp, err = client.GetPostThreadWithOpts(context.Background(), th.BasicPost.Id, "", model.GetPostsOptions{ + UpdatesOnly: true, + FromUpdateAt: 12345, + Direction: "down", + }) + require.NoError(t, err) + CheckOKStatus(t, resp) + + list, resp, err = client.GetPostThreadWithOpts(context.Background(), th.BasicPost.Id, "", model.GetPostsOptions{ + UpdatesOnly: true, + Direction: "down", + FromUpdateAt: post.UpdateAt, + }) + require.NoError(t, err) + CheckOKStatus(t, resp) + assert.Len(t, list.Order, 1) + assert.Len(t, list.Posts, 1) + require.Equal(t, th.BasicPost.Id, list.Order[0], "wrong order") + + // Test with just fromUpdateAt parameter + _, resp, err = client.GetPostThreadWithOpts(context.Background(), th.BasicPost.Id, "", model.GetPostsOptions{ + FromUpdateAt: 12345, + }) + require.NoError(t, err) + CheckOKStatus(t, resp) + + // Sending other bad params unrelated to the new changes _, resp, err = client.GetPostThreadWithOpts(context.Background(), th.BasicPost.Id, "", model.GetPostsOptions{ CollapsedThreads: true, FromPost: "something", diff --git a/server/channels/store/sqlstore/post_store.go b/server/channels/store/sqlstore/post_store.go index b5d8efd6e0..a48959db99 100644 --- a/server/channels/store/sqlstore/post_store.go +++ b/server/channels/store/sqlstore/post_store.go @@ -627,36 +627,50 @@ func (s *SqlPostStore) getPostWithCollapsedThreads(id, userID string, opts model } } if sort != "" { - query = query.OrderBy("CreateAt " + sort + ", Id " + sort) + if opts.UpdatesOnly { + query = query.OrderBy("UpdateAt " + sort + ", Id " + sort) + } else { + query = query.OrderBy("CreateAt " + sort + ", Id " + sort) + } } if opts.FromCreateAt != 0 { + var direction sq.Sqlizer + var pagination sq.Sqlizer + if opts.Direction == "down" { - direction := sq.Gt{"Posts.CreateAt": opts.FromCreateAt} - if opts.FromPost != "" { - query = query.Where(sq.Or{ - direction, - sq.And{ - sq.Eq{"Posts.CreateAt": opts.FromCreateAt}, - sq.Gt{"Posts.Id": opts.FromPost}, - }, - }) - } else { - query = query.Where(direction) - } + direction = sq.Gt{"Posts.CreateAt": opts.FromCreateAt} + pagination = sq.Gt{"Posts.Id": opts.FromPost} } else { - direction := sq.Lt{"Posts.CreateAt": opts.FromCreateAt} - if opts.FromPost != "" { - query = query.Where(sq.Or{ - direction, - sq.And{ - sq.Eq{"Posts.CreateAt": opts.FromCreateAt}, - sq.Lt{"Posts.Id": opts.FromPost}, - }, - }) - } else { - query = query.Where(direction) - } + direction = sq.Lt{"Posts.CreateAt": opts.FromCreateAt} + pagination = sq.Lt{"Posts.Id": opts.FromPost} + } + + if opts.FromPost != "" { + query = query.Where(sq.Or{ + direction, + sq.And{ + sq.Eq{"Posts.CreateAt": opts.FromCreateAt}, + pagination, + }, + }) + } else { + query = query.Where(direction) + } + } + + if opts.FromUpdateAt != 0 && opts.Direction == "down" { + direction := sq.Gt{"Posts.UpdateAt": opts.FromUpdateAt} + if opts.FromPost != "" { + query = query.Where(sq.Or{ + direction, + sq.And{ + sq.Eq{"Posts.UpdateAt": opts.FromUpdateAt}, + sq.Gt{"Posts.Id": opts.FromPost}, + }, + }) + } else { + query = query.Where(direction) } } @@ -773,7 +787,11 @@ func (s *SqlPostStore) Get(ctx context.Context, id string, opts model.GetPostsOp } } if sort != "" { - query = query.OrderBy("CreateAt " + sort + ", Id " + sort) + if opts.UpdatesOnly { + query = query.OrderBy("UpdateAt " + sort + ", Id " + sort) + } else { + query = query.OrderBy("CreateAt " + sort + ", Id " + sort) + } } if opts.FromCreateAt != 0 { @@ -806,6 +824,36 @@ func (s *SqlPostStore) Get(ctx context.Context, id string, opts model.GetPostsOp } } + if opts.FromUpdateAt != 0 { + if opts.Direction == "down" { + direction := sq.Gt{"p.UpdateAt": opts.FromUpdateAt} + if opts.FromPost != "" { + query = query.Where(sq.Or{ + direction, + sq.And{ + sq.Eq{"p.UpdateAt": opts.FromUpdateAt}, + sq.Gt{"p.Id": opts.FromPost}, + }, + }) + } else { + query = query.Where(direction) + } + } else { + direction := sq.Lt{"p.UpdateAt": opts.FromUpdateAt} + if opts.FromPost != "" { + query = query.Where(sq.Or{ + direction, + sq.And{ + sq.Eq{"p.UpdateAt": opts.FromUpdateAt}, + sq.Lt{"p.Id": opts.FromPost}, + }, + }) + } else { + query = query.Where(direction) + } + } + } + if opts.PerPage != 0 { query = query.Limit(uint64(opts.PerPage + 1)) } diff --git a/server/channels/store/storetest/post_store.go b/server/channels/store/storetest/post_store.go index 7d23a0b6b5..e38205c522 100644 --- a/server/channels/store/storetest/post_store.go +++ b/server/channels/store/storetest/post_store.go @@ -838,7 +838,7 @@ func testPostStoreGetForThread(t *testing.T, rctx request.CTX, ss store.Store) { } r1, err = ss.Post().Get(context.Background(), o1.Id, opts, o1.UserId, map[string]bool{}) require.NoError(t, err) - require.Len(t, r1.Order, 2) // including the root post + require.Len(t, r1.Order, 2) require.Len(t, r1.Posts, 2) assert.True(t, *r1.HasNext) @@ -876,7 +876,7 @@ func testPostStoreGetForThread(t *testing.T, rctx request.CTX, ss store.Store) { } r1, err = ss.Post().Get(context.Background(), o1.Id, opts, o1.UserId, map[string]bool{}) require.NoError(t, err) - require.Len(t, r1.Order, 2) // including the root post + require.Len(t, r1.Order, 2) require.Len(t, r1.Posts, 2) assert.LessOrEqual(t, r1.Posts[r1.Order[1]].CreateAt, firstPostCreateAt) assert.False(t, *r1.HasNext) @@ -896,6 +896,126 @@ func testPostStoreGetForThread(t *testing.T, rctx request.CTX, ss store.Store) { assert.GreaterOrEqual(t, r1.Posts[r1.Order[1]].CreateAt, m1.CreateAt) assert.True(t, *r1.HasNext) }) + + t.Run("Pagination with UpdateAt", func(t *testing.T) { + teamID := model.NewId() + channel, err := ss.Channel().Save(rctx, &model.Channel{ + TeamId: teamID, + DisplayName: "DisplayName1", + Name: "channel" + model.NewId(), + Type: model.ChannelTypeOpen, + }, -1) + require.NoError(t, err) + + now := model.GetMillis() + o1 := &model.Post{CreateAt: now, ChannelId: channel.Id, UserId: model.NewId(), Message: NewTestID()} + o1, err = ss.Post().Save(rctx, o1) + require.NoError(t, err) + + // Create replies with explicit UpdateAt timestamps + o2 := &model.Post{CreateAt: now + 1, UpdateAt: now + 1, ChannelId: o1.ChannelId, UserId: model.NewId(), Message: NewTestID(), RootId: o1.Id} + _, err = ss.Post().Save(rctx, o2) + require.NoError(t, err) + + m1 := &model.Post{CreateAt: now + 2, UpdateAt: now + 2, ChannelId: o1.ChannelId, UserId: model.NewId(), Message: NewTestID(), RootId: o1.Id} + m1, err = ss.Post().Save(rctx, m1) + require.NoError(t, err) + + o3 := &model.Post{CreateAt: now + 3, UpdateAt: now + 3, ChannelId: o1.ChannelId, UserId: model.NewId(), Message: NewTestID(), RootId: o1.Id} + _, err = ss.Post().Save(rctx, o3) + require.NoError(t, err) + + o4 := &model.Post{CreateAt: now + 4, UpdateAt: now + 4, ChannelId: o1.ChannelId, UserId: model.NewId(), Message: NewTestID(), RootId: o1.Id} + o4, err = ss.Post().Save(rctx, o4) + require.NoError(t, err) + + // Test pagination with UpdateAt in "down" direction + opts := model.GetPostsOptions{ + UpdatesOnly: true, + CollapsedThreads: true, + PerPage: 2, + Direction: "down", + } + r1, err := ss.Post().Get(context.Background(), o1.Id, opts, o1.UserId, map[string]bool{}) + require.NoError(t, err) + require.Len(t, r1.Order, 3) // including the root post + require.Len(t, r1.Posts, 3) + assert.Equal(t, r1.Posts[r1.Order[0]].UpdateAt, o4.CreateAt) // The root post always get updated with the createAt of the latest post in the thread. + assert.True(t, *r1.HasNext) + + lastPostID := r1.Order[len(r1.Order)-1] + lastPostUpdateAt := r1.Posts[lastPostID].UpdateAt + + // Continue pagination using UpdateAt + opts = model.GetPostsOptions{ + UpdatesOnly: true, + CollapsedThreads: true, + PerPage: 2, + Direction: "down", + FromPost: lastPostID, + FromUpdateAt: lastPostUpdateAt, + } + r1, err = ss.Post().Get(context.Background(), o1.Id, opts, o1.UserId, map[string]bool{}) + require.NoError(t, err) + require.Len(t, r1.Order, 3) // including the root post + require.Len(t, r1.Posts, 3) + assert.GreaterOrEqual(t, r1.Posts[r1.Order[len(r1.Order)-1]].UpdateAt, lastPostUpdateAt) + assert.Equal(t, r1.Posts[r1.Order[0]].UpdateAt, o4.CreateAt) // The root post always get updated with the createAt of the latest post in the thread. + assert.False(t, *r1.HasNext) + + // Non-CRT mode with UpdateAt pagination + opts = model.GetPostsOptions{ + UpdatesOnly: true, + CollapsedThreads: false, + PerPage: 2, + Direction: "down", + SkipFetchThreads: false, + } + r1, err = ss.Post().Get(context.Background(), o1.Id, opts, o1.UserId, map[string]bool{}) + require.NoError(t, err) + // Ordering by updateAt will move the root post down, so we will get more posts in the thread. + require.Len(t, r1.Order, 3) + require.Len(t, r1.Posts, 3) + require.True(t, *r1.HasNext) + + lastPostID = r1.Order[len(r1.Order)-1] + lastPostUpdateAt = r1.Posts[lastPostID].UpdateAt + + opts = model.GetPostsOptions{ + UpdatesOnly: true, + CollapsedThreads: false, + PerPage: 3, + Direction: "down", + FromPost: lastPostID, + FromUpdateAt: lastPostUpdateAt, + SkipFetchThreads: false, + } + r1, err = ss.Post().Get(context.Background(), o1.Id, opts, o1.UserId, map[string]bool{}) + require.NoError(t, err) + require.Len(t, r1.Order, 3) + require.Len(t, r1.Posts, 3) + require.Equal(t, r1.Posts[r1.Order[0]].ReplyCount, int64(4)) + require.Equal(t, r1.Posts[r1.Order[1]].ReplyCount, int64(4)) + require.Equal(t, r1.Posts[r1.Order[2]].ReplyCount, int64(4)) + require.GreaterOrEqual(t, r1.Posts[r1.Order[len(r1.Order)-1]].UpdateAt, lastPostUpdateAt) + assert.False(t, *r1.HasNext) + + // Only with UpdateAt - direction down + opts = model.GetPostsOptions{ + UpdatesOnly: true, + CollapsedThreads: false, + PerPage: 1, + Direction: "down", + FromUpdateAt: m1.UpdateAt, + SkipFetchThreads: false, + } + r1, err = ss.Post().Get(context.Background(), o1.Id, opts, o1.UserId, map[string]bool{}) + require.NoError(t, err) + require.Len(t, r1.Order, 2) + require.Len(t, r1.Posts, 2) + require.GreaterOrEqual(t, r1.Posts[r1.Order[1]].UpdateAt, m1.UpdateAt) + require.True(t, *r1.HasNext) + }) } func testPostStoreGetSingle(t *testing.T, rctx request.CTX, ss store.Store) { diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 8c150e7255..aedb244f42 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -4168,6 +4168,9 @@ func (c *Client4) GetPostThreadWithOpts(ctx context.Context, postID string, etag if opts.SkipFetchThreads { values.Set("skipFetchThreads", "true") } + if opts.UpdatesOnly { + values.Set("updatesOnly", "true") + } if opts.PerPage != 0 { values.Set("perPage", strconv.Itoa(opts.PerPage)) } @@ -4177,6 +4180,9 @@ func (c *Client4) GetPostThreadWithOpts(ctx context.Context, postID string, etag if opts.FromCreateAt != 0 { values.Set("fromCreateAt", strconv.FormatInt(opts.FromCreateAt, 10)) } + if opts.FromUpdateAt != 0 { + values.Set("fromUpdateAt", strconv.FormatInt(opts.FromUpdateAt, 10)) + } if opts.Direction != "" { values.Set("direction", opts.Direction) } diff --git a/server/public/model/post.go b/server/public/model/post.go index 7c77bc99f6..d29170977e 100644 --- a/server/public/model/post.go +++ b/server/public/model/post.go @@ -403,7 +403,9 @@ type GetPostsOptions struct { CollapsedThreadsExtended bool FromPost string // PostId after which to send the items FromCreateAt int64 // CreateAt after which to send the items + FromUpdateAt int64 // UpdateAt after which to send the items. This cannot be used with FromCreateAt. Direction string // Only accepts up|down. Indicates the order in which to send the items. + UpdatesOnly bool // This flag is used to make the API work with the updateAt value. IncludeDeleted bool IncludePostPriority bool } diff --git a/webapp/channels/src/actions/views/threads.ts b/webapp/channels/src/actions/views/threads.ts index 1e310478c2..c4a81c5772 100644 --- a/webapp/channels/src/actions/views/threads.ts +++ b/webapp/channels/src/actions/views/threads.ts @@ -24,6 +24,16 @@ export function updateThreadLastOpened(threadId: string, lastViewedAt: number) { }; } +export function updateThreadLastUpdateAt(threadId: string, lastUpdateAt: number) { + return { + type: Threads.CHANGED_LAST_UPDATE_AT, + data: { + threadId, + lastUpdateAt, + }, + }; +} + export function setSelectedThreadId(teamId: string, threadId: string | undefined) { return { type: Threads.CHANGED_SELECTED_THREAD, diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index b2d08ae1da..e1f3c60808 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -847,9 +847,11 @@ async function handlePostDeleteEvent(msg) { } } else { const res = await dispatch(getPostThread(post.root_id)); - const {order, posts} = res.data; - const rootPost = posts[order[0]]; - dispatch(receivedPost(rootPost)); + if (res.data) { + const {order, posts} = res.data; + const rootPost = posts[order[0]]; + dispatch(receivedPost(rootPost)); + } } } diff --git a/webapp/channels/src/components/threading/thread_viewer/index.ts b/webapp/channels/src/components/threading/thread_viewer/index.ts index 813c902e88..3ae3996284 100644 --- a/webapp/channels/src/components/threading/thread_viewer/index.ts +++ b/webapp/channels/src/components/threading/thread_viewer/index.ts @@ -22,8 +22,9 @@ import {getThread} from 'mattermost-redux/selectors/entities/threads'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {selectPostCard} from 'actions/views/rhs'; -import {updateThreadLastOpened} from 'actions/views/threads'; +import {updateThreadLastOpened, updateThreadLastUpdateAt} from 'actions/views/threads'; import {getHighlightedPostId, getSelectedPostFocussedAt} from 'selectors/rhs'; +import {getThreadLastUpdateAt} from 'selectors/views/threads'; import {getSocketStatus} from 'selectors/views/websocket'; import type {GlobalState} from 'types/store'; @@ -51,11 +52,13 @@ function makeMapStateToProps() { let postIds: string[] = []; let userThread: UserThread | null = null; let channel: Channel | undefined; + let lastUpdateAt: number = 0; if (selected) { postIds = getPostIdsForThread(state, selected.id); userThread = getThread(state, selected.id); channel = getChannel(state, selected.channel_id); + lastUpdateAt = getThreadLastUpdateAt(state, selected.id); } return { @@ -71,6 +74,7 @@ function makeMapStateToProps() { highlightedPostId, selectedPostFocusedAt, enableWebSocketEventScope, + lastUpdateAt, }; }; } @@ -85,6 +89,7 @@ function mapDispatchToProps(dispatch: Dispatch) { selectPostCard, updateThreadLastOpened, updateThreadRead, + updateThreadLastUpdateAt, }, dispatch), }; } diff --git a/webapp/channels/src/components/threading/thread_viewer/thread_viewer.test.tsx b/webapp/channels/src/components/threading/thread_viewer/thread_viewer.test.tsx index e29ca45ad3..6387008803 100644 --- a/webapp/channels/src/components/threading/thread_viewer/thread_viewer.test.tsx +++ b/webapp/channels/src/components/threading/thread_viewer/thread_viewer.test.tsx @@ -54,6 +54,7 @@ describe('components/threading/ThreadViewer', () => { getThread: jest.fn(), updateThreadRead: jest.fn(), updateThreadLastOpened: jest.fn(), + updateThreadLastUpdateAt: jest.fn(), fetchRHSAppsBindings: jest.fn(), }; @@ -70,8 +71,23 @@ describe('components/threading/ThreadViewer', () => { rootPostId: post.id, isThreadView: true, enableWebSocketEventScope: false, + lastUpdateAt: 1234, }; + beforeEach(() => { + // Reset and redefine the mock before each test + actions.getPostThread.mockReset().mockResolvedValue({ + data: { + order: ['post1', 'post2', 'post3'], + posts: { + post1: TestHelper.getPostMock({id: 'post1', update_at: 1000}), + post2: TestHelper.getPostMock({id: 'post2', update_at: 2000}), + post3: TestHelper.getPostMock({id: 'post3', update_at: 1500}), + }, + }, + }); + }); + test('should match snapshot', async () => { const reset = fakeDate(new Date(1502715365000)); @@ -92,7 +108,7 @@ describe('components/threading/ThreadViewer', () => { wrapper.setProps({socketConnectionStatus: false}); wrapper.setProps({socketConnectionStatus: true}); - return expect(actions.getPostThread).toHaveBeenCalledWith(post.id, true); + return expect(actions.getPostThread).toHaveBeenCalledWith(post.id, true, 1234); }); test('should not break if root post is a fake post', () => { @@ -244,4 +260,48 @@ describe('components/threading/ThreadViewer', () => { expect(actions.fetchRHSAppsBindings).not.toHaveBeenCalledWith('channel_id', 'id'); }); + + test('should update thread with highest update_at value when lastUpdateAt is 0', async () => { + const {actions} = baseProps; + + shallow( + , + ); + + await new Promise(process.nextTick); + + // Verify getPostThread was called with lastUpdateAt = 0 + expect(actions.getPostThread).toHaveBeenCalledWith(post.id, true, 0); + + // Verify updateThreadLastUpdateAt was called with the highest update_at value + expect(actions.updateThreadLastUpdateAt).toHaveBeenCalledWith(post.id, 2000); + }); + + test('should handle case where root post has the highest update_at value', async () => { + // Mock with root post having highest update_at + actions.getPostThread.mockReset().mockResolvedValue({ + data: { + order: ['post1', 'post2', 'post3'], + posts: { + post1: TestHelper.getPostMock({id: 'post1', update_at: 9000}), // Highest value (root post) + post2: TestHelper.getPostMock({id: 'post2', update_at: 2000}), + post3: TestHelper.getPostMock({id: 'post3', update_at: 1500}), + }, + }, + }); + + shallow( + , + ); + + await new Promise(process.nextTick); + + // Verify updateThreadLastUpdateAt was called with the highest update_at value + expect(actions.updateThreadLastUpdateAt).toHaveBeenCalledWith(post.id, 9000); + }); }); diff --git a/webapp/channels/src/components/threading/thread_viewer/thread_viewer.tsx b/webapp/channels/src/components/threading/thread_viewer/thread_viewer.tsx index bf2b271f59..03d8791598 100644 --- a/webapp/channels/src/components/threading/thread_viewer/thread_viewer.tsx +++ b/webapp/channels/src/components/threading/thread_viewer/thread_viewer.tsx @@ -40,11 +40,12 @@ export type Props = Attrs & { actions: { fetchRHSAppsBindings: (channelId: string, rootID: string) => unknown; getNewestPostThread: (rootId: string) => Promise; - getPostThread: (rootId: string, fetchThreads: boolean) => Promise; + getPostThread: (rootId: string, fetchThreads: boolean, lastUpdateAt: number) => Promise; getThread: (userId: string, teamId: string, threadId: string, extended: boolean) => Promise; selectPostCard: (post: Post) => void; updateThreadLastOpened: (threadId: string, lastViewedAt: number) => unknown; updateThreadRead: (userId: string, teamId: string, threadId: string, timestamp: number) => unknown; + updateThreadLastUpdateAt: (threadId: string, lastUpdateAt: number) => unknown; }; useRelativeTimestamp?: boolean; postIds: string[]; @@ -54,6 +55,7 @@ export type Props = Attrs & { inputPlaceholder?: string; rootPostId: string; enableWebSocketEventScope: boolean; + lastUpdateAt: number; }; type State = { @@ -95,7 +97,6 @@ export default class ThreadViewer extends React.PureComponent { } const selectedChanged = this.props.selected.id !== prevProps.selected?.id; - if (reconnected || selectedChanged) { this.onInit(reconnected); } @@ -108,7 +109,7 @@ export default class ThreadViewer extends React.PureComponent { } if (this.props.appsEnabled && ( - this.props.channel?.id !== prevProps.channel?.id || this.props.selected.id !== prevProps.selected?.id + this.props.channel?.id !== prevProps.channel?.id || selectedChanged )) { this.props.actions.fetchRHSAppsBindings(this.props.channel?.id || '', this.props.selected.id); } @@ -173,7 +174,26 @@ export default class ThreadViewer extends React.PureComponent { // scrolls to either bottom or new messages line private onInit = async (reconnected = false): Promise => { this.setState({isLoading: !reconnected}); - await this.props.actions.getPostThread(this.props.selected?.id || this.props.rootPostId, !reconnected); + const res = await this.props.actions.getPostThread(this.props.selected?.id || this.props.rootPostId, !reconnected, this.props.lastUpdateAt); + + if (this.props.selected && res.data) { + const {order, posts} = res.data; + if (order.length > 0 && posts[order[0]]) { + let highestUpdateAt = posts[order[0]].update_at; + + // Check all posts to find the highest update_at + for (const postId in posts) { + if (Object.hasOwn(posts, postId)) { + const post = posts[postId]; + if (post.update_at > highestUpdateAt) { + highestUpdateAt = post.update_at; + } + } + } + + this.props.actions.updateThreadLastUpdateAt(this.props.selected.id, highestUpdateAt); + } + } if ( this.props.isCollapsedThreadsEnabled && diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts index 2d41c90558..b7ff7af196 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/posts.ts @@ -614,11 +614,20 @@ async function getPaginatedPostThread(rootId: string, options: FetchPaginatedThr if (result.has_next) { const [nextPostId] = list.order!.slice(-1); const nextPostPointer = list.posts[nextPostId]; - const newOptions = { - ...options, - fromCreateAt: nextPostPointer.create_at, - fromPost: nextPostId, - }; + let newOptions; + if (options.updatesOnly) { + newOptions = { + ...options, + fromUpdateAt: nextPostPointer.update_at, + fromPost: nextPostId, + }; + } else { + newOptions = { + ...options, + fromCreateAt: nextPostPointer.create_at, + fromPost: nextPostId, + }; + } return getPaginatedPostThread(rootId, newOptions, list); } @@ -626,15 +635,23 @@ async function getPaginatedPostThread(rootId: string, options: FetchPaginatedThr return list; } -export function getPostThread(rootId: string, fetchThreads = true): ActionFuncAsync { +export function getPostThread(rootId: string, fetchThreads = true, lastUpdateAt = 0): ActionFuncAsync { return async (dispatch, getState) => { const state = getState(); const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); const enabledUserStatuses = getIsUserStatusesConfigEnabled(state); let posts; + const options: FetchPaginatedThreadOptions = { + fetchThreads, + collapsedThreads: collapsedThreadsEnabled, + }; + if (lastUpdateAt !== 0) { + options.updatesOnly = true; + options.fromUpdateAt = lastUpdateAt; + } try { - posts = await getPaginatedPostThread(rootId, {fetchThreads, collapsedThreads: collapsedThreadsEnabled}); + posts = await getPaginatedPostThread(rootId, options); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); diff --git a/webapp/channels/src/reducers/views/threads.ts b/webapp/channels/src/reducers/views/threads.ts index 9156c84474..4a87294755 100644 --- a/webapp/channels/src/reducers/views/threads.ts +++ b/webapp/channels/src/reducers/views/threads.ts @@ -51,6 +51,18 @@ export const lastViewedAt = (state: ViewsState['threads']['lastViewedAt'] = {}, } }; +export const lastUpdateAt = (state: ViewsState['threads']['lastUpdateAt'] = {}, action: MMAction) => { + switch (action.type) { + case Threads.CHANGED_LAST_UPDATE_AT: + return { + ...state, + [action.data.threadId]: action.data.lastUpdateAt, + }; + default: + return state; + } +}; + export function manuallyUnread(state: ViewsState['threads']['manuallyUnread'] = {}, action: MMAction) { switch (action.type) { case Threads.CHANGED_LAST_VIEWED_AT: @@ -90,4 +102,5 @@ export default combineReducers({ lastViewedAt, manuallyUnread, toastStatus, + lastUpdateAt, }); diff --git a/webapp/channels/src/selectors/views/threads.ts b/webapp/channels/src/selectors/views/threads.ts index 97e7c4f00e..da3abd798e 100644 --- a/webapp/channels/src/selectors/views/threads.ts +++ b/webapp/channels/src/selectors/views/threads.ts @@ -87,6 +87,10 @@ export const isThreadManuallyUnread = (state: GlobalState, threadId: UserThread[ return state.views.threads.manuallyUnread[threadId] || false; }; +export const getThreadLastUpdateAt = (state: GlobalState, threadId: UserThread['id']): number => { + return state.views.threads.lastUpdateAt[threadId] || 0; +}; + // Returns a selector that, given the state and an object containing an array of postIds and an optional // timestamp of when the channel was last read, returns a memoized array of postIds interspersed with // day indicators, an optional new message indicator and create comment. diff --git a/webapp/channels/src/types/store/views.ts b/webapp/channels/src/types/store/views.ts index af281da2ba..3a2fc3295e 100644 --- a/webapp/channels/src/types/store/views.ts +++ b/webapp/channels/src/types/store/views.ts @@ -207,6 +207,7 @@ export type ViewsState = { threads: { selectedThreadIdInTeam: RelationOneToOne; lastViewedAt: {[id: string]: number}; + lastUpdateAt: {[id: string]: number}; manuallyUnread: {[id: string]: boolean}; toastStatus: boolean; }; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index ed9b10ad07..1826eee776 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -747,6 +747,7 @@ export const Threads = { CHANGED_SELECTED_THREAD: 'changed_selected_thread', CHANGED_LAST_VIEWED_AT: 'changed_last_viewed_at', MANUALLY_UNREAD_THREAD: 'manually_unread_thread', + CHANGED_LAST_UPDATE_AT: 'changed_last_update_at', }; export const CloudBanners = { diff --git a/webapp/platform/types/src/client4.ts b/webapp/platform/types/src/client4.ts index d3453c9c39..5661097810 100644 --- a/webapp/platform/types/src/client4.ts +++ b/webapp/platform/types/src/client4.ts @@ -37,9 +37,11 @@ export type FetchPaginatedThreadOptions = { fetchThreads?: boolean; collapsedThreads?: boolean; collapsedThreadsExtended?: boolean; + updatesOnly?: boolean; // This indicates the API is meant to be used to only get delta updates. direction?: 'up'|'down'; fetchAll?: boolean; perPage?: number; fromCreateAt?: number; + fromUpdateAt?: number; fromPost?: string; }