From 12a136580ea1f9b33489dae2bd574d5e7681bc1c Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Thu, 11 Jun 2026 16:23:55 -0300 Subject: [PATCH 1/5] poc: paginate threads --- .../client/lib/utils/threadMessageUtils.ts | 69 +++++++++++-- .../Threads/components/ThreadMessageList.tsx | 35 ++++++- .../Threads/hooks/useThreadMessagesQuery.ts | 98 +++++++++++++------ 3 files changed, 158 insertions(+), 44 deletions(-) diff --git a/apps/meteor/client/lib/utils/threadMessageUtils.ts b/apps/meteor/client/lib/utils/threadMessageUtils.ts index d78bfc800b5da..ce97f03c0b8b0 100644 --- a/apps/meteor/client/lib/utils/threadMessageUtils.ts +++ b/apps/meteor/client/lib/utils/threadMessageUtils.ts @@ -1,6 +1,6 @@ import type { IMessage, IThreadMessage, MessageAttachment } from '@rocket.chat/core-typings'; import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter'; -import type { QueryClient } from '@tanstack/react-query'; +import type { InfiniteData, QueryClient } from '@tanstack/react-query'; import type { Condition, Filter } from 'mongodb'; import { queryClient as defaultQueryClient } from '../queryClient'; @@ -47,6 +47,48 @@ export const createDeleteCriteria = (params: NotifyRoomRidDeleteBulkEvent): ((me return createPredicateFromFilter(query); }; +export type ThreadMessagesPage = { + items: IThreadMessage[]; + itemCount: number; +}; + +export type ThreadMessagesInfiniteData = InfiniteData; + +export const mutateThreadMessagesInfiniteData = ( + client: QueryClient, + queryKey: readonly unknown[], + mutation: (messages: IThreadMessage[]) => void, +): void => { + client.setQueryData(queryKey, (old) => { + if (!old?.pages.length) { + return old; + } + + const items = old.pages.flatMap((page) => page.items); + const lastPage = old.pages.at(-1) ?? { items: [], itemCount: 0 }; + + const beforeMutationItemsLength = items.length; + mutation(items); + const afterMutationItemsLength = items.length; + + const pageSize = lastPage.items.length || items.length; + if (pageSize === 0) { + return old; + } + + const newPageCount = Math.ceil(items.length / pageSize); + const itemCountDelta = beforeMutationItemsLength - afterMutationItemsLength; + + return { + pages: Array.from({ length: newPageCount }, (_, pageIndex) => ({ + items: items.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize), + itemCount: lastPage.itemCount - itemCountDelta, + })), + pageParams: Array.from({ length: newPageCount }, (_, pageIndex) => pageIndex * pageSize), + }; + }); +}; + export const upsertThreadMessageInCache = ( message: IMessage, rid: IMessage['rid'], @@ -54,17 +96,24 @@ export const upsertThreadMessageInCache = ( client: QueryClient = defaultQueryClient, ): void => { const queryKey = roomsQueryKeys.threadMessages(rid, tmid); - client.setQueryData(queryKey, (old) => { - if (!old) { - return [message]; - } - const idx = old.findIndex((m) => m._id === message._id); + + if (!client.getQueryData(queryKey)) { + client.setQueryData(queryKey, { + pages: [{ items: [message as IThreadMessage], itemCount: 1 }], + pageParams: [0], + }); + return; + } + + mutateThreadMessagesInfiniteData(client, queryKey, (messages) => { + const idx = messages.findIndex((m) => m._id === message._id); if (idx >= 0) { - const updated = [...old]; - updated[idx] = message; - return updated; + messages[idx] = message as IThreadMessage; + return; } - return [...old, message].sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime()); + + messages.push(message as IThreadMessage); + messages.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime()); }); }; 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 8da9a927cb22c..9edd56ba2bbd5 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx @@ -1,5 +1,6 @@ import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; +import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; import { MessageTypes } from '@rocket.chat/message-types'; import { isTruthy } from '@rocket.chat/tools'; import { clientCallbacks, CustomVirtuaScrollbars } from '@rocket.chat/ui-client'; @@ -11,6 +12,7 @@ import type { VirtualizerHandle } from 'virtua'; import { VList } from 'virtua'; import { ThreadMessageItem } from './ThreadMessageItem'; +import InfiniteListAnchor from '../../../../../components/InfiniteListAnchor'; import { useMergedRefsV2 } from '../../../../../hooks/useMergedRefsV2'; import { setMessageJumpQueryStringParameter } from '../../../../../lib/utils/setMessageJumpQueryStringParameter'; import { BubbleDate } from '../../../BubbleDate'; @@ -61,7 +63,18 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot const msgJumpParam = useSearchParameter('msg'); const { bubbleRef, handleDateScroll, ...bubbleDate } = useDateScroll(); - const { data: messages = [], isLoading: loading } = useThreadMessagesQuery(mainMessage._id); + const { data, isLoading: loading, fetchNextPage, hasNextPage, isFetchingNextPage } = useThreadMessagesQuery(mainMessage._id); + const messages = data?.messages ?? []; + + const loadMoreMessages = useDebouncedCallback( + () => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + 100, + [hasNextPage, isFetchingNextPage, fetchNextPage], + ); const room = useRoom(); const uid = useUserId(); @@ -89,6 +102,19 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot }); }, [messagesLength, setKeepAtBottom, msgJumpParam]); + useEffect(() => { + if (loading || isFetchingNextPage || !hasNextPage || !msgJumpParam) { + return; + } + if (msgJumpParam === mainMessage._id) { + return; + } + if (messages.some((message) => message._id === msgJumpParam)) { + return; + } + void fetchNextPage(); + }, [loading, isFetchingNextPage, hasNextPage, msgJumpParam, messages, mainMessage._id, fetchNextPage]); + const mergedRefs = useMergedRefsV2(messageListRef, keepAtBottomRef); const lastScrollSizeRef = useRef(0); @@ -215,7 +241,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot ref={virtualizerRef} style={{ height: '100%' }} aria-label={t('Thread_message_list')} - aria-busy={loading} + aria-busy={loading || isFetchingNextPage} role='list' keepMounted={keepMountedMessages} onScroll={(offset) => { @@ -259,6 +285,11 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot ); }) )} + {!loading && hasNextPage ? ( +
  • + {isFetchingNextPage ? : } +
  • + ) : null} 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 5966aa17cad4f..dd9cd13e22132 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts @@ -1,15 +1,19 @@ import { isThreadMessage, type IMessage, type IRoom, type IThreadMainMessage, type IThreadMessage } from '@rocket.chat/core-typings'; -import { useMethod, useStream } from '@rocket.chat/ui-contexts'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEndpoint, useMethod, useStream } from '@rocket.chat/ui-contexts'; +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef } from 'react'; import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived'; import { roomsQueryKeys } from '../../../../../lib/queryKeys'; +import { getConfig } from '../../../../../lib/utils/getConfig'; +import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi'; import { modifyMessageOnFilesDelete } from '../../../../../lib/utils/modifyMessageOnFilesDelete'; import { createDeleteCriteria, markThreadMessagesAsRead, mergeThreadMessages, + mutateThreadMessagesInfiniteData, + type ThreadMessagesInfiniteData, upsertThreadMessageInCache, } from '../../../../../lib/utils/threadMessageUtils'; import { useRoom } from '../../../contexts/RoomContext'; @@ -18,6 +22,12 @@ const processMessages = async (messages: IMessage[]): Promise => { return Promise.all(messages.map((msg) => onClientMessageReceived(msg))); }; +const filterThreadMessages = (messages: IMessage[], tmid: IThreadMainMessage['_id']): IThreadMessage[] => { + return messages + .map((m) => mapMessageFromApi(m)) + .filter((msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true); +}; + export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IRoom['_id']) => { const room = useRoom(); const roomId = rid ?? room._id; @@ -31,6 +41,8 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR const unprocessedReadMessagesEvent = useRef<{ tmid: string; until: Date } | null>(null); + const count = parseInt(`${getConfig('threadMessagesSize', 50)}`, 10); + useEffect(() => { const currentQueryKey = roomsQueryKeys.threadMessages(roomId, tmid); @@ -44,32 +56,30 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR }); const unsubscribeFromDeleteMessage = subscribeToNotifyRoom(`${roomId}/deleteMessage`, (event) => { - queryClient.setQueryData(currentQueryKey, (old) => { - if (!old) { - return old; + mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => { + const index = messages.findIndex((m) => m._id === event._id); + if (index !== -1) { + messages.splice(index, 1); } - return old.filter((m) => m._id !== event._id); }); }); const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom(`${roomId}/deleteMessageBulk`, (bulkParams) => { const matchDeleteCriteria = createDeleteCriteria(bulkParams); - queryClient.setQueryData(currentQueryKey, (old) => { - if (!old) { - return old; - } - + mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => { if (bulkParams.filesOnly) { - return old.map((msg) => { + for (let index = 0; index < messages.length; index++) { + const msg = messages[index]; if (matchDeleteCriteria(msg)) { - return modifyMessageOnFilesDelete(msg, bulkParams.replaceFileAttachmentsWith); + messages[index] = modifyMessageOnFilesDelete(msg, bulkParams.replaceFileAttachmentsWith); } - return msg; - }); + } + return; } - return old.filter((msg) => !matchDeleteCriteria(msg)); + const filtered = messages.filter((msg) => !matchDeleteCriteria(msg)); + messages.splice(0, messages.length, ...filtered); }); }); @@ -84,11 +94,9 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR return; } - queryClient.setQueryData(currentQueryKey, (old) => { - if (!old) { - return old; - } - return markThreadMessagesAsRead(old, until); + mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => { + const updated = markThreadMessagesAsRead(messages, until); + messages.splice(0, messages.length, ...updated); }); }); @@ -100,23 +108,49 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR }; }, [tmid, roomId, queryClient, subscribeToRoomMessages, subscribeToNotifyRoom]); - return useQuery({ + return useInfiniteQuery({ queryKey, - queryFn: async () => { - const cachedMessages = queryClient.getQueryData(queryKey) || []; + queryFn: async ({ pageParam: offset }) => { + if (offset === 0) { + void Promise.resolve(readThreads(tmid)).catch(() => undefined); + } - const messages = await getThreadMessages({ tmid }); - const filtered = messages.filter( - (msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true, - ); + const cachedData = offset === 0 ? queryClient.getQueryData(queryKey) : undefined; + const cachedMessages = cachedData?.pages[0]?.items ?? []; - const sorted = mergeThreadMessages(cachedMessages, filtered); - if (unprocessedReadMessagesEvent.current) { + const { messages, total } = await getThreadMessages({ + tmid, + offset, + count, + sort: JSON.stringify({ ts: 1 }), + }); + + let filtered = filterThreadMessages(messages, tmid); + if (offset === 0) { + filtered = mergeThreadMessages(cachedMessages, filtered); + } + + if (offset === 0 && unprocessedReadMessagesEvent.current) { const { until } = unprocessedReadMessagesEvent.current; unprocessedReadMessagesEvent.current = null; - return processMessages(markThreadMessagesAsRead(sorted, until)) as Promise>; + filtered = markThreadMessagesAsRead(filtered, until); } - return processMessages(sorted) as Promise>; + + const processed = (await processMessages(filtered)) as IThreadMessage[]; + + return { + items: processed, + itemCount: total, + }; + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + const loadedItemsCount = allPages.reduce((acc, page) => acc + page.items.length, 0); + return loadedItemsCount < lastPage.itemCount ? loadedItemsCount : undefined; }, + select: ({ pages }) => ({ + messages: pages.flatMap((page) => page.items), + itemCount: pages.at(-1)?.itemCount ?? 0, + }), }); }; From 5270d11459cdadbe9d7a31c68930b7f16cb60c5c Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Wed, 17 Jun 2026 17:23:45 -0300 Subject: [PATCH 2/5] chore: restore endpoint --- .../contextualBar/Threads/hooks/useThreadMessagesQuery.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 dd9cd13e22132..f864d7c3d4754 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts @@ -34,7 +34,11 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR const queryClient = useQueryClient(); const queryKey = roomsQueryKeys.threadMessages(roomId, tmid); - const getThreadMessages = useMethod('getThreadMessages'); + const getThreadMessages = useEndpoint('GET', '/v1/chat.getThreadMessages'); + // REST has no per-thread read-marker endpoint yet; fall back to the + // `readThreads` DDP method so the side effect that DDP getThreadMessages + // used to do server-side keeps happening for callers. + const readThreads = useMethod('readThreads'); const subscribeToRoomMessages = useStream('room-messages'); const subscribeToNotifyRoom = useStream('notify-room'); From add1e18f22cc6bd6f7742fe873096c8c88201cbc Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Wed, 17 Jun 2026 18:34:42 -0300 Subject: [PATCH 3/5] chore: loadaround and jump to message --- apps/meteor/app/api/server/v1/chat.ts | 24 ++++++-- .../client/lib/utils/threadMessageUtils.ts | 27 +++++---- .../Threads/components/ThreadMessageList.tsx | 59 +++++++++++++++++-- .../Threads/hooks/useThreadMessagesQuery.ts | 54 ++++++++++++++--- packages/rest-typings/src/v1/chat.ts | 6 ++ 5 files changed, 139 insertions(+), 31 deletions(-) diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 62378bd436e7a..cf9af87c1184e 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -1135,7 +1135,7 @@ const chatEndpoints = API.v1 }, }, async function action() { - const { tmid } = this.queryParams; + const { tmid, aroundId } = this.queryParams; const { query, fields, sort } = await this.parseJsonQuery(); const { offset, count } = await getPaginationItems(this.queryParams); @@ -1153,11 +1153,27 @@ const chatEndpoints = API.v1 if (!room || !user || !(await canAccessRoomAsync(room, user))) { throw new Meteor.Error('error-not-allowed', 'Not Allowed'); } + + let resolvedOffset = offset; + let resolvedSort = sort || { ts: 1 }; + if (aroundId) { + resolvedSort = { ts: 1 }; + if (aroundId === tmid) { + resolvedOffset = 0; + } else { + const target = await Messages.findOneById(aroundId, { projection: { ts: 1, tmid: 1 } }); + if (target?.tmid === tmid && target.ts) { + const before = await Messages.countDocuments({ tmid, ts: { $lt: target.ts } }); + resolvedOffset = Math.max(0, before - Math.floor(count / 2)); + } + } + } + const { cursor, totalCount } = Messages.findPaginated( { ...query, tmid }, { - sort: sort || { ts: 1 }, - skip: offset, + sort: resolvedSort, + skip: resolvedOffset, limit: count, projection: fields, }, @@ -1168,7 +1184,7 @@ const chatEndpoints = API.v1 return API.v1.success({ messages, count: messages.length, - offset, + offset: resolvedOffset, total, }); }, diff --git a/apps/meteor/client/lib/utils/threadMessageUtils.ts b/apps/meteor/client/lib/utils/threadMessageUtils.ts index ce97f03c0b8b0..98b36f011e617 100644 --- a/apps/meteor/client/lib/utils/threadMessageUtils.ts +++ b/apps/meteor/client/lib/utils/threadMessageUtils.ts @@ -65,26 +65,29 @@ export const mutateThreadMessagesInfiniteData = ( } const items = old.pages.flatMap((page) => page.items); - const lastPage = old.pages.at(-1) ?? { items: [], itemCount: 0 }; + const originalPageLengths = old.pages.map((page) => page.items.length); + const oldTotal = old.pages.at(-1)?.itemCount ?? 0; const beforeMutationItemsLength = items.length; mutation(items); const afterMutationItemsLength = items.length; - const pageSize = lastPage.items.length || items.length; - if (pageSize === 0) { - return old; - } - - const newPageCount = Math.ceil(items.length / pageSize); const itemCountDelta = beforeMutationItemsLength - afterMutationItemsLength; + const newTotal = Math.max(0, oldTotal - itemCountDelta); + + const pages: ThreadMessagesPage[] = []; + let cursor = 0; + for (let pageIndex = 0; pageIndex < old.pages.length; pageIndex++) { + const isLastPage = pageIndex === old.pages.length - 1; + const take = isLastPage ? items.length - cursor : Math.min(originalPageLengths[pageIndex], items.length - cursor); + const slice = items.slice(cursor, cursor + Math.max(0, take)); + cursor += slice.length; + pages.push({ items: slice, itemCount: newTotal }); + } return { - pages: Array.from({ length: newPageCount }, (_, pageIndex) => ({ - items: items.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize), - itemCount: lastPage.itemCount - itemCountDelta, - })), - pageParams: Array.from({ length: newPageCount }, (_, pageIndex) => pageIndex * pageSize), + pages, + pageParams: old.pageParams, }; }); }; 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 9edd56ba2bbd5..b29509b789221 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx @@ -6,7 +6,7 @@ import { isTruthy } from '@rocket.chat/tools'; import { clientCallbacks, CustomVirtuaScrollbars } from '@rocket.chat/ui-client'; import { useSearchParameter, useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts'; import { differenceInSeconds } from 'date-fns'; -import { Fragment, useEffect, useMemo, useRef } from 'react'; +import { Fragment, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import type { VirtualizerHandle } from 'virtua'; import { VList } from 'virtua'; @@ -63,7 +63,17 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot const msgJumpParam = useSearchParameter('msg'); const { bubbleRef, handleDateScroll, ...bubbleDate } = useDateScroll(); - const { data, isLoading: loading, fetchNextPage, hasNextPage, isFetchingNextPage } = useThreadMessagesQuery(mainMessage._id); + const { + data, + isLoading: loading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + fetchPreviousPage, + hasPreviousPage, + isFetchingPreviousPage, + loadMessageAround, + } = useThreadMessagesQuery(mainMessage._id); const messages = data?.messages ?? []; const loadMoreMessages = useDebouncedCallback( @@ -76,6 +86,16 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot [hasNextPage, isFetchingNextPage, fetchNextPage], ); + const loadPreviousMessages = useDebouncedCallback( + () => { + if (hasPreviousPage && !isFetchingPreviousPage) { + void fetchPreviousPage(); + } + }, + 100, + [hasPreviousPage, isFetchingPreviousPage, fetchPreviousPage], + ); + const room = useRoom(); const uid = useUserId(); @@ -90,6 +110,16 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot const isAtBottom = useRef(null); const prevItemsLengthRef = useRef(0); + // When older replies are prepended (loading previous pages), virtua must shift the + // scroll position so the viewport stays anchored on the same message instead of + // jumping. The flag is armed near the top in onScroll and reset after every commit, + // so appends at the bottom (new messages) don't shift. + // https://inokawa.github.io/virtua/?path=/story/advanced-chat--default + const isPrepend = useRef(false); + useLayoutEffect(() => { + isPrepend.current = false; + }); + const { keepAtBottomRef, setKeepAtBottom } = useKeepAtBottom(isAtBottom); const messagesLength = messages.length; useEffect(() => { @@ -101,9 +131,10 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot } }); }, [messagesLength, setKeepAtBottom, msgJumpParam]); + const loadingWindowKeyRef = useRef(undefined); useEffect(() => { - if (loading || isFetchingNextPage || !hasNextPage || !msgJumpParam) { + if (loading || !msgJumpParam || isFetchingNextPage || isFetchingPreviousPage) { return; } if (msgJumpParam === mainMessage._id) { @@ -112,8 +143,13 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot if (messages.some((message) => message._id === msgJumpParam)) { return; } - void fetchNextPage(); - }, [loading, isFetchingNextPage, hasNextPage, msgJumpParam, messages, mainMessage._id, fetchNextPage]); + const windowKey = `${mainMessage._id}:${msgJumpParam}`; + if (loadingWindowKeyRef.current === windowKey) { + return; + } + loadingWindowKeyRef.current = windowKey; + void loadMessageAround(msgJumpParam); + }, [loading, isFetchingNextPage, isFetchingPreviousPage, msgJumpParam, messages, mainMessage._id, loadMessageAround]); const mergedRefs = useMergedRefsV2(messageListRef, keepAtBottomRef); @@ -136,6 +172,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot useEffect(() => { lastThreadJumpKeyRef.current = undefined; + loadingWindowKeyRef.current = undefined; prevItemsLengthRef.current = 0; }, [mainMessage._id]); @@ -239,15 +276,20 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot { const handle = virtualizerRef.current; if (!handle) return; + if (offset < 200 && hasPreviousPage) { + isPrepend.current = true; + } + // Copied from messageList, I'm unsure why this is necessary, but it seems to be needed to properly set the isAtBottom state if (handle.scrollSize >= handle.viewportSize) { isAtBottom.current = true; @@ -281,6 +323,11 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot firstUnread={firstUnread} system={system} /> + {index === 0 && hasPreviousPage ? ( +
  • + {isFetchingPreviousPage ? : } +
  • + ) : null} ); }) 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 f864d7c3d4754..160ad94673116 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts @@ -1,7 +1,7 @@ import { isThreadMessage, type IMessage, type IRoom, type IThreadMainMessage, type IThreadMessage } from '@rocket.chat/core-typings'; import { useEndpoint, useMethod, useStream } from '@rocket.chat/ui-contexts'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived'; import { roomsQueryKeys } from '../../../../../lib/queryKeys'; @@ -112,7 +112,29 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR }; }, [tmid, roomId, queryClient, subscribeToRoomMessages, subscribeToNotifyRoom]); - return useInfiniteQuery({ + const loadMessageAround = useCallback( + async (messageId: string) => { + const currentQueryKey = roomsQueryKeys.threadMessages(roomId, tmid); + + const { messages, total, offset } = await getThreadMessages({ + tmid, + aroundId: messageId, + count, + sort: JSON.stringify({ ts: 1 }), + }); + + const filtered = filterThreadMessages(messages, tmid); + const processed = (await processMessages(filtered)) as IThreadMessage[]; + + queryClient.setQueryData(currentQueryKey, { + pages: [{ items: processed, itemCount: total }], + pageParams: [offset], + }); + }, + [queryClient, getThreadMessages, roomId, tmid, count], + ); + + const query = useInfiniteQuery({ queryKey, queryFn: async ({ pageParam: offset }) => { if (offset === 0) { @@ -148,13 +170,27 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR }; }, initialPageParam: 0, - getNextPageParam: (lastPage, allPages) => { - const loadedItemsCount = allPages.reduce((acc, page) => acc + page.items.length, 0); - return loadedItemsCount < lastPage.itemCount ? loadedItemsCount : undefined; + getNextPageParam: (lastPage, _allPages, lastPageParam) => { + const next = lastPageParam + count; + return next < lastPage.itemCount ? next : undefined; + }, + getPreviousPageParam: (_firstPage, _allPages, firstPageParam) => { + return firstPageParam > 0 ? Math.max(0, firstPageParam - count) : undefined; + }, + select: ({ pages }) => { + const byId = new Map(); + for (const page of pages) { + for (const item of page.items) { + byId.set(item._id, item); + } + } + const messages = Array.from(byId.values()).sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime()); + return { + messages, + itemCount: pages.at(-1)?.itemCount ?? 0, + }; }, - select: ({ pages }) => ({ - messages: pages.flatMap((page) => page.items), - itemCount: pages.at(-1)?.itemCount ?? 0, - }), }); + + return { ...query, loadMessageAround }; }; diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 575bf87ecadb8..f4803b02943fd 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -683,6 +683,7 @@ export const isChatSyncThreadMessagesProps = ajvQuery.compile; const ChatGetThreadMessagesSchema = { @@ -692,6 +693,11 @@ const ChatGetThreadMessagesSchema = { type: 'string', minLength: 1, }, + aroundId: { + type: 'string', + minLength: 1, + nullable: true, + }, count: { type: 'number', nullable: true, From 53210d17531a4de56daec14178c2f125c690d149 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Thu, 18 Jun 2026 09:21:41 -0300 Subject: [PATCH 4/5] chore: lint --- .../contextualBar/Threads/hooks/useThreadMessagesQuery.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 160ad94673116..64d496f672e42 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts @@ -1,4 +1,5 @@ -import { isThreadMessage, type IMessage, type IRoom, type IThreadMainMessage, type IThreadMessage } from '@rocket.chat/core-typings'; +import type { IRoom, IMessage, IThreadMainMessage, IThreadMessage, Serialized } from '@rocket.chat/core-typings'; +import { isThreadMessage } from '@rocket.chat/core-typings'; import { useEndpoint, useMethod, useStream } from '@rocket.chat/ui-contexts'; import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback, useEffect, useRef } from 'react'; @@ -22,7 +23,7 @@ const processMessages = async (messages: IMessage[]): Promise => { return Promise.all(messages.map((msg) => onClientMessageReceived(msg))); }; -const filterThreadMessages = (messages: IMessage[], tmid: IThreadMainMessage['_id']): IThreadMessage[] => { +const filterThreadMessages = (messages: Serialized[], tmid: IThreadMainMessage['_id']): IThreadMessage[] => { return messages .map((m) => mapMessageFromApi(m)) .filter((msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true); From 1a4a180368702f9b9e083907886ded6f14a750f4 Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Mon, 29 Jun 2026 17:19:38 -0300 Subject: [PATCH 5/5] fix: prepend --- .../Threads/components/ThreadMessageList.tsx | 65 ++++++++++++------- .../Threads/hooks/useThreadMessagesQuery.ts | 18 ++--- 2 files changed, 50 insertions(+), 33 deletions(-) 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 b29509b789221..c68eb3ddc5107 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx @@ -74,7 +74,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot isFetchingPreviousPage, loadMessageAround, } = useThreadMessagesQuery(mainMessage._id); - const messages = data?.messages ?? []; + const messages = useMemo(() => data?.messages ?? [], [data?.messages]); const loadMoreMessages = useDebouncedCallback( () => { @@ -86,9 +86,11 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot [hasNextPage, isFetchingNextPage, fetchNextPage], ); + const initialScrollDoneRef = useRef(false); + const loadPreviousMessages = useDebouncedCallback( () => { - if (hasPreviousPage && !isFetchingPreviousPage) { + if (initialScrollDoneRef.current && isAtBottom.current !== true && hasPreviousPage && !isFetchingPreviousPage) { void fetchPreviousPage(); } }, @@ -110,16 +112,6 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot const isAtBottom = useRef(null); const prevItemsLengthRef = useRef(0); - // When older replies are prepended (loading previous pages), virtua must shift the - // scroll position so the viewport stays anchored on the same message instead of - // jumping. The flag is armed near the top in onScroll and reset after every commit, - // so appends at the bottom (new messages) don't shift. - // https://inokawa.github.io/virtua/?path=/story/advanced-chat--default - const isPrepend = useRef(false); - useLayoutEffect(() => { - isPrepend.current = false; - }); - const { keepAtBottomRef, setKeepAtBottom } = useKeepAtBottom(isAtBottom); const messagesLength = messages.length; useEffect(() => { @@ -155,7 +147,26 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot const lastScrollSizeRef = useRef(0); - const items = loading ? [] : [mainMessage, ...messages]; + const showMainMessage = !hasPreviousPage; + const items = useMemo(() => { + if (loading) { + return []; + } + return showMainMessage ? [mainMessage, ...messages] : messages; + }, [loading, showMainMessage, mainMessage, messages]); + + const firstItemId = items[0]?._id; + const prevFirstItemIdRef = useRef(undefined); + const prevItemsCountRef = useRef(0); + const isPrepend = + prevItemsCountRef.current > 0 && + items.length > prevItemsCountRef.current && + firstItemId !== undefined && + firstItemId !== prevFirstItemIdRef.current; + useLayoutEffect(() => { + prevFirstItemIdRef.current = firstItemId; + prevItemsCountRef.current = items.length; + }); const threadMsgTargetIndex = useMemo(() => { if (!msgJumpParam || loading) { @@ -165,8 +176,11 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot return 0; } const replyIndex = messages.findIndex((m) => m._id === msgJumpParam); - return replyIndex >= 0 ? 1 + replyIndex : -1; - }, [msgJumpParam, loading, mainMessage._id, messages]); + if (replyIndex < 0) { + return -1; + } + return showMainMessage ? 1 + replyIndex : replyIndex; + }, [msgJumpParam, loading, mainMessage._id, messages, showMainMessage]); const lastThreadJumpKeyRef = useRef(undefined); @@ -174,6 +188,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot lastThreadJumpKeyRef.current = undefined; loadingWindowKeyRef.current = undefined; prevItemsLengthRef.current = 0; + initialScrollDoneRef.current = false; }, [mainMessage._id]); useEffect(() => { @@ -208,6 +223,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot isAtBottom.current = true; handle.scrollToIndex(items.length, { align: 'end' }); setShouldJumpToBottom(false); + initialScrollDoneRef.current = true; } }, [items, loading, msgJumpParam, threadMsgTargetIndex, shouldJumpToBottom, setShouldJumpToBottom, uid]); @@ -226,6 +242,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot lastThreadJumpKeyRef.current = jumpKey; setShouldJumpToBottom(false); handle.scrollToIndex(threadMsgTargetIndex, { align: 'center' }); + initialScrollDoneRef.current = true; setHighlightMessage(msgJumpParam); setTimeout(() => { clearHighlightMessage(); @@ -276,7 +293,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot - ) : ( + ) : null} + {!loading && hasPreviousPage ? ( +
  • {isFetchingPreviousPage ? : null}
  • + ) : null} + {!loading && items.map((message, index, { [index - 1]: previous }) => { const sequential = isMessageSequential(message, previous, messageGroupingPeriod); const newDay = isMessageNewDay(message, previous); @@ -323,15 +344,9 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot firstUnread={firstUnread} system={system} /> - {index === 0 && hasPreviousPage ? ( -
  • - {isFetchingPreviousPage ? : } -
  • - ) : null} ); - }) - )} + })} {!loading && hasNextPage ? (
  • {isFetchingNextPage ? : } 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 64d496f672e42..19792be76418f 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts @@ -127,9 +127,11 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR const filtered = filterThreadMessages(messages, tmid); const processed = (await processMessages(filtered)) as IThreadMessage[]; + const pageParam = Math.max(0, total - offset - count); + queryClient.setQueryData(currentQueryKey, { pages: [{ items: processed, itemCount: total }], - pageParams: [offset], + pageParams: [pageParam], }); }, [queryClient, getThreadMessages, roomId, tmid, count], @@ -143,13 +145,13 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR } const cachedData = offset === 0 ? queryClient.getQueryData(queryKey) : undefined; - const cachedMessages = cachedData?.pages[0]?.items ?? []; + const cachedMessages = cachedData?.pages.at(-1)?.items ?? []; const { messages, total } = await getThreadMessages({ tmid, offset, count, - sort: JSON.stringify({ ts: 1 }), + sort: JSON.stringify({ ts: -1 }), }); let filtered = filterThreadMessages(messages, tmid); @@ -171,12 +173,12 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR }; }, initialPageParam: 0, - getNextPageParam: (lastPage, _allPages, lastPageParam) => { - const next = lastPageParam + count; - return next < lastPage.itemCount ? next : undefined; + getNextPageParam: (_lastPage, _allPages, lastPageParam) => { + return lastPageParam > 0 ? Math.max(0, lastPageParam - count) : undefined; }, - getPreviousPageParam: (_firstPage, _allPages, firstPageParam) => { - return firstPageParam > 0 ? Math.max(0, firstPageParam - count) : undefined; + getPreviousPageParam: (firstPage, _allPages, firstPageParam) => { + const next = firstPageParam + count; + return next < firstPage.itemCount ? next : undefined; }, select: ({ pages }) => { const byId = new Map();