diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx index 6274221f0e7e1..99937c97eb2a8 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx @@ -73,6 +73,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot hasPreviousPage, isFetchingPreviousPage, loadMessageAround, + jumpToRecent, } = useThreadMessagesQuery(mainMessage._id); const messages = useMemo(() => data?.messages ?? [], [data?.messages]); @@ -259,7 +260,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot prevItemsLengthRef.current = items.length; if (items.length > prev && uid) { const lastItem = items.at(-1); - if (lastItem?.temp && lastItem.u._id === uid) { + if (lastItem?.temp && lastItem.u._id === uid && !hasNextPage) { setShouldJumpToBottom(true); } } @@ -287,6 +288,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot uid, isFetchingPreviousPage, isFetchingNextPage, + hasNextPage, ]); useEffect(() => { @@ -354,6 +356,10 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot return; } if (msg.u._id === uid) { + if (hasNextPage) { + void jumpToRecent().then(() => setShouldJumpToBottom(true)); + return; + } setShouldJumpToBottom(true); } }, @@ -364,7 +370,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot return () => { clientCallbacks.remove('streamNewMessage', handlerId); }; - }, [room._id, uid, mainMessage._id, setShouldJumpToBottom]); + }, [room._id, uid, mainMessage._id, setShouldJumpToBottom, hasNextPage, jumpToRecent]); const keepMountedMessages = useKeepMountedMessages(items); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.spec.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.spec.ts new file mode 100644 index 0000000000000..b1fc29a0d4f90 --- /dev/null +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.spec.ts @@ -0,0 +1,132 @@ +import type { IMessage, Serialized } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { useThreadMessagesQuery } from './useThreadMessagesQuery'; +import { createFakeRoom } from '../../../../../../tests/mocks/data'; + +const room = createFakeRoom({ _id: 'room-id', t: 'c' }); + +jest.mock('../../../contexts/RoomContext', () => ({ + useRoom: () => room, +})); + +const TMID = 'thread-id'; +const TOTAL = 120; +const PAGE_SIZE = 50; + +const createSerializedReply = (index: number): Serialized => { + const ts = new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString(); + + return { + _id: `reply-${index}`, + rid: room._id, + tmid: TMID, + msg: `reply ${index}`, + ts, + _updatedAt: ts, + u: { _id: 'user-id', username: 'user', name: 'User' }, + } as unknown as Serialized; +}; + +const allReplies = Array.from({ length: TOTAL }, (_, index) => createSerializedReply(index)); + +const idsOf = (messages: { _id: string }[]) => messages.map(({ _id }) => _id); + +const newestPageIds = idsOf(allReplies.slice(TOTAL - PAGE_SIZE)); + +type GetThreadMessagesParams = { tmid: string; offset?: number; count?: number; aroundId?: string; sort?: string }; + +const setup = () => { + const getThreadMessages = jest.fn(({ offset = 0, count = PAGE_SIZE, aroundId }: GetThreadMessagesParams) => { + if (aroundId) { + const index = allReplies.findIndex(({ _id }) => _id === aroundId); + const start = Math.max(0, index - Math.floor(count / 2)); + + return { messages: allReplies.slice(start, start + count), count, offset: start, total: TOTAL }; + } + + const end = TOTAL - offset; + const start = Math.max(0, end - count); + + return { messages: allReplies.slice(start, end), count, offset, total: TOTAL }; + }); + + const wrapper = mockAppRoot() + .withJohnDoe() + .withEndpoint('GET', '/v1/chat.getThreadMessages', getThreadMessages) + .withEndpoint('POST', '/v1/chat.readThread', () => null) + .build(); + + const { result } = renderHook(() => useThreadMessagesQuery(TMID), { wrapper }); + + return { result, getThreadMessages }; +}; + +const waitForLoaded = async (result: { current: ReturnType }) => + waitFor(() => expect(result.current.isLoading).toBe(false)); + +describe('useThreadMessagesQuery', () => { + it('reports no unloaded newer messages after the initial load', async () => { + const { result } = setup(); + + await waitForLoaded(result); + + expect(result.current.hasNextPage).toBe(false); + expect(result.current.hasPreviousPage).toBe(true); + expect(idsOf(result.current.data?.messages ?? [])).toEqual(newestPageIds); + }); + + it('reports unloaded newer messages after jumping to an old reply', async () => { + const { result } = setup(); + + await waitForLoaded(result); + + await act(async () => { + await result.current.loadMessageAround('reply-10'); + }); + + await waitFor(() => expect(result.current.hasNextPage).toBe(true)); + expect(idsOf(result.current.data?.messages ?? [])).toEqual(idsOf(allReplies.slice(0, PAGE_SIZE))); + }); + + describe('jumpToRecent', () => { + it('refetches from the newest page and clears the unloaded-newer-messages flag', async () => { + const { result, getThreadMessages } = setup(); + + await waitForLoaded(result); + + await act(async () => { + await result.current.loadMessageAround('reply-10'); + }); + await waitFor(() => expect(result.current.hasNextPage).toBe(true)); + + getThreadMessages.mockClear(); + + await act(async () => { + await result.current.jumpToRecent(); + }); + + await waitFor(() => expect(result.current.hasNextPage).toBe(false)); + expect(getThreadMessages).toHaveBeenCalledWith(expect.objectContaining({ tmid: TMID, offset: 0 })); + }); + + it('discards the detached window instead of merging it into the newest page', async () => { + const { result } = setup(); + + await waitForLoaded(result); + + await act(async () => { + await result.current.loadMessageAround('reply-10'); + }); + await waitFor(() => expect(result.current.hasNextPage).toBe(true)); + + await act(async () => { + await result.current.jumpToRecent(); + }); + + await waitFor(() => expect(result.current.hasNextPage).toBe(false)); + expect(idsOf(result.current.data?.messages ?? [])).toEqual(newestPageIds); + }); + }); +}); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts index 3feb844b6f23a..bc1a49e456c3f 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts @@ -138,6 +138,10 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR [queryClient, getThreadMessages, roomId, tmid, count], ); + const jumpToRecent = useCallback(async () => { + await queryClient.resetQueries({ queryKey: roomsQueryKeys.threadMessages(roomId, tmid) }); + }, [queryClient, roomId, tmid]); + const query = useInfiniteQuery({ queryKey, queryFn: async ({ pageParam: offset }) => { @@ -201,5 +205,5 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR refetchOnWindowFocus: false, }); - return { ...query, loadMessageAround }; + return { ...query, loadMessageAround, jumpToRecent }; };