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 d78bfc800b5da..98b36f011e617 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,51 @@ 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 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 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, + pageParams: old.pageParams, + }; + }); +}; + export const upsertThreadMessageInCache = ( message: IMessage, rid: IMessage['rid'], @@ -54,17 +99,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..c68eb3ddc5107 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx @@ -1,16 +1,18 @@ 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'; 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'; 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,40 @@ 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, + fetchPreviousPage, + hasPreviousPage, + isFetchingPreviousPage, + loadMessageAround, + } = useThreadMessagesQuery(mainMessage._id); + const messages = useMemo(() => data?.messages ?? [], [data?.messages]); + + const loadMoreMessages = useDebouncedCallback( + () => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + 100, + [hasNextPage, isFetchingNextPage, fetchNextPage], + ); + + const initialScrollDoneRef = useRef(false); + + const loadPreviousMessages = useDebouncedCallback( + () => { + if (initialScrollDoneRef.current && isAtBottom.current !== true && hasPreviousPage && !isFetchingPreviousPage) { + void fetchPreviousPage(); + } + }, + 100, + [hasPreviousPage, isFetchingPreviousPage, fetchPreviousPage], + ); const room = useRoom(); const uid = useUserId(); @@ -88,12 +123,50 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot } }); }, [messagesLength, setKeepAtBottom, msgJumpParam]); + const loadingWindowKeyRef = useRef(undefined); + + useEffect(() => { + if (loading || !msgJumpParam || isFetchingNextPage || isFetchingPreviousPage) { + return; + } + if (msgJumpParam === mainMessage._id) { + return; + } + if (messages.some((message) => message._id === msgJumpParam)) { + return; + } + 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); 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) { @@ -103,14 +176,19 @@ 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); useEffect(() => { lastThreadJumpKeyRef.current = undefined; + loadingWindowKeyRef.current = undefined; prevItemsLengthRef.current = 0; + initialScrollDoneRef.current = false; }, [mainMessage._id]); useEffect(() => { @@ -145,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]); @@ -163,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(); @@ -213,15 +293,20 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot { const handle = virtualizerRef.current; if (!handle) return; + if (offset < 200 && hasPreviousPage) { + loadPreviousMessages(); + } + // 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; @@ -236,7 +321,11 @@ 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); @@ -257,8 +346,12 @@ 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..19792be76418f 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,20 @@ -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 { useEffect, useRef } from 'react'; +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'; 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,19 +23,31 @@ const processMessages = async (messages: IMessage[]): Promise => { return Promise.all(messages.map((msg) => onClientMessageReceived(msg))); }; +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); +}; + export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IRoom['_id']) => { const room = useRoom(); const roomId = rid ?? room._id; 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'); 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 +61,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 +99,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 +113,87 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR }; }, [tmid, roomId, queryClient, subscribeToRoomMessages, subscribeToNotifyRoom]); - return useQuery({ + 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[]; + + const pageParam = Math.max(0, total - offset - count); + + queryClient.setQueryData(currentQueryKey, { + pages: [{ items: processed, itemCount: total }], + pageParams: [pageParam], + }); + }, + [queryClient, getThreadMessages, roomId, tmid, count], + ); + + const query = useInfiniteQuery({ queryKey, - queryFn: async () => { - const cachedMessages = queryClient.getQueryData(queryKey) || []; + queryFn: async ({ pageParam: offset }) => { + if (offset === 0) { + void Promise.resolve(readThreads(tmid)).catch(() => undefined); + } + + const cachedData = offset === 0 ? queryClient.getQueryData(queryKey) : undefined; + const cachedMessages = cachedData?.pages.at(-1)?.items ?? []; - 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 { messages, total } = await getThreadMessages({ + tmid, + offset, + count, + sort: JSON.stringify({ ts: -1 }), + }); - const sorted = mergeThreadMessages(cachedMessages, filtered); - if (unprocessedReadMessagesEvent.current) { + 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, lastPageParam) => { + return lastPageParam > 0 ? Math.max(0, lastPageParam - count) : undefined; + }, + getPreviousPageParam: (firstPage, _allPages, firstPageParam) => { + const next = firstPageParam + count; + return next < firstPage.itemCount ? next : 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, + }; }, }); + + 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,