Skip to content

Commit 12a1365

Browse files
poc: paginate threads
1 parent 275a6ed commit 12a1365

3 files changed

Lines changed: 158 additions & 44 deletions

File tree

apps/meteor/client/lib/utils/threadMessageUtils.ts

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { IMessage, IThreadMessage, MessageAttachment } from '@rocket.chat/core-typings';
22
import { createPredicateFromFilter } from '@rocket.chat/mongo-adapter';
3-
import type { QueryClient } from '@tanstack/react-query';
3+
import type { InfiniteData, QueryClient } from '@tanstack/react-query';
44
import type { Condition, Filter } from 'mongodb';
55

66
import { queryClient as defaultQueryClient } from '../queryClient';
@@ -47,24 +47,73 @@ export const createDeleteCriteria = (params: NotifyRoomRidDeleteBulkEvent): ((me
4747
return createPredicateFromFilter(query);
4848
};
4949

50+
export type ThreadMessagesPage = {
51+
items: IThreadMessage[];
52+
itemCount: number;
53+
};
54+
55+
export type ThreadMessagesInfiniteData = InfiniteData<ThreadMessagesPage, number>;
56+
57+
export const mutateThreadMessagesInfiniteData = (
58+
client: QueryClient,
59+
queryKey: readonly unknown[],
60+
mutation: (messages: IThreadMessage[]) => void,
61+
): void => {
62+
client.setQueryData<ThreadMessagesInfiniteData>(queryKey, (old) => {
63+
if (!old?.pages.length) {
64+
return old;
65+
}
66+
67+
const items = old.pages.flatMap((page) => page.items);
68+
const lastPage = old.pages.at(-1) ?? { items: [], itemCount: 0 };
69+
70+
const beforeMutationItemsLength = items.length;
71+
mutation(items);
72+
const afterMutationItemsLength = items.length;
73+
74+
const pageSize = lastPage.items.length || items.length;
75+
if (pageSize === 0) {
76+
return old;
77+
}
78+
79+
const newPageCount = Math.ceil(items.length / pageSize);
80+
const itemCountDelta = beforeMutationItemsLength - afterMutationItemsLength;
81+
82+
return {
83+
pages: Array.from({ length: newPageCount }, (_, pageIndex) => ({
84+
items: items.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
85+
itemCount: lastPage.itemCount - itemCountDelta,
86+
})),
87+
pageParams: Array.from({ length: newPageCount }, (_, pageIndex) => pageIndex * pageSize),
88+
};
89+
});
90+
};
91+
5092
export const upsertThreadMessageInCache = (
5193
message: IMessage,
5294
rid: IMessage['rid'],
5395
tmid: IMessage['_id'],
5496
client: QueryClient = defaultQueryClient,
5597
): void => {
5698
const queryKey = roomsQueryKeys.threadMessages(rid, tmid);
57-
client.setQueryData<IMessage[]>(queryKey, (old) => {
58-
if (!old) {
59-
return [message];
60-
}
61-
const idx = old.findIndex((m) => m._id === message._id);
99+
100+
if (!client.getQueryData<ThreadMessagesInfiniteData>(queryKey)) {
101+
client.setQueryData<ThreadMessagesInfiniteData>(queryKey, {
102+
pages: [{ items: [message as IThreadMessage], itemCount: 1 }],
103+
pageParams: [0],
104+
});
105+
return;
106+
}
107+
108+
mutateThreadMessagesInfiniteData(client, queryKey, (messages) => {
109+
const idx = messages.findIndex((m) => m._id === message._id);
62110
if (idx >= 0) {
63-
const updated = [...old];
64-
updated[idx] = message;
65-
return updated;
111+
messages[idx] = message as IThreadMessage;
112+
return;
66113
}
67-
return [...old, message].sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
114+
115+
messages.push(message as IThreadMessage);
116+
messages.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
68117
});
69118
};
70119

apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings';
22
import { isEditedMessage } from '@rocket.chat/core-typings';
3+
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
34
import { MessageTypes } from '@rocket.chat/message-types';
45
import { isTruthy } from '@rocket.chat/tools';
56
import { clientCallbacks, CustomVirtuaScrollbars } from '@rocket.chat/ui-client';
@@ -11,6 +12,7 @@ import type { VirtualizerHandle } from 'virtua';
1112
import { VList } from 'virtua';
1213

1314
import { ThreadMessageItem } from './ThreadMessageItem';
15+
import InfiniteListAnchor from '../../../../../components/InfiniteListAnchor';
1416
import { useMergedRefsV2 } from '../../../../../hooks/useMergedRefsV2';
1517
import { setMessageJumpQueryStringParameter } from '../../../../../lib/utils/setMessageJumpQueryStringParameter';
1618
import { BubbleDate } from '../../../BubbleDate';
@@ -61,7 +63,18 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
6163
const msgJumpParam = useSearchParameter('msg');
6264
const { bubbleRef, handleDateScroll, ...bubbleDate } = useDateScroll();
6365

64-
const { data: messages = [], isLoading: loading } = useThreadMessagesQuery(mainMessage._id);
66+
const { data, isLoading: loading, fetchNextPage, hasNextPage, isFetchingNextPage } = useThreadMessagesQuery(mainMessage._id);
67+
const messages = data?.messages ?? [];
68+
69+
const loadMoreMessages = useDebouncedCallback(
70+
() => {
71+
if (hasNextPage && !isFetchingNextPage) {
72+
void fetchNextPage();
73+
}
74+
},
75+
100,
76+
[hasNextPage, isFetchingNextPage, fetchNextPage],
77+
);
6578

6679
const room = useRoom();
6780
const uid = useUserId();
@@ -89,6 +102,19 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
89102
});
90103
}, [messagesLength, setKeepAtBottom, msgJumpParam]);
91104

105+
useEffect(() => {
106+
if (loading || isFetchingNextPage || !hasNextPage || !msgJumpParam) {
107+
return;
108+
}
109+
if (msgJumpParam === mainMessage._id) {
110+
return;
111+
}
112+
if (messages.some((message) => message._id === msgJumpParam)) {
113+
return;
114+
}
115+
void fetchNextPage();
116+
}, [loading, isFetchingNextPage, hasNextPage, msgJumpParam, messages, mainMessage._id, fetchNextPage]);
117+
92118
const mergedRefs = useMergedRefsV2(messageListRef, keepAtBottomRef);
93119

94120
const lastScrollSizeRef = useRef(0);
@@ -215,7 +241,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
215241
ref={virtualizerRef}
216242
style={{ height: '100%' }}
217243
aria-label={t('Thread_message_list')}
218-
aria-busy={loading}
244+
aria-busy={loading || isFetchingNextPage}
219245
role='list'
220246
keepMounted={keepMountedMessages}
221247
onScroll={(offset) => {
@@ -259,6 +285,11 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
259285
);
260286
})
261287
)}
288+
{!loading && hasNextPage ? (
289+
<li className='load-more'>
290+
{isFetchingNextPage ? <LoadingMessagesIndicator /> : <InfiniteListAnchor loadMore={loadMoreMessages} />}
291+
</li>
292+
) : null}
262293
</VList>
263294
</MessageListProvider>
264295
</CustomVirtuaScrollbars>
Lines changed: 66 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { isThreadMessage, type IMessage, type IRoom, type IThreadMainMessage, type IThreadMessage } from '@rocket.chat/core-typings';
2-
import { useMethod, useStream } from '@rocket.chat/ui-contexts';
3-
import { useQuery, useQueryClient } from '@tanstack/react-query';
2+
import { useEndpoint, useMethod, useStream } from '@rocket.chat/ui-contexts';
3+
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
44
import { useEffect, useRef } from 'react';
55

66
import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived';
77
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
8+
import { getConfig } from '../../../../../lib/utils/getConfig';
9+
import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi';
810
import { modifyMessageOnFilesDelete } from '../../../../../lib/utils/modifyMessageOnFilesDelete';
911
import {
1012
createDeleteCriteria,
1113
markThreadMessagesAsRead,
1214
mergeThreadMessages,
15+
mutateThreadMessagesInfiniteData,
16+
type ThreadMessagesInfiniteData,
1317
upsertThreadMessageInCache,
1418
} from '../../../../../lib/utils/threadMessageUtils';
1519
import { useRoom } from '../../../contexts/RoomContext';
@@ -18,6 +22,12 @@ const processMessages = async (messages: IMessage[]): Promise<IMessage[]> => {
1822
return Promise.all(messages.map((msg) => onClientMessageReceived(msg)));
1923
};
2024

25+
const filterThreadMessages = (messages: IMessage[], tmid: IThreadMainMessage['_id']): IThreadMessage[] => {
26+
return messages
27+
.map((m) => mapMessageFromApi(m))
28+
.filter((msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true);
29+
};
30+
2131
export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IRoom['_id']) => {
2232
const room = useRoom();
2333
const roomId = rid ?? room._id;
@@ -31,6 +41,8 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR
3141

3242
const unprocessedReadMessagesEvent = useRef<{ tmid: string; until: Date } | null>(null);
3343

44+
const count = parseInt(`${getConfig('threadMessagesSize', 50)}`, 10);
45+
3446
useEffect(() => {
3547
const currentQueryKey = roomsQueryKeys.threadMessages(roomId, tmid);
3648

@@ -44,32 +56,30 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR
4456
});
4557

4658
const unsubscribeFromDeleteMessage = subscribeToNotifyRoom(`${roomId}/deleteMessage`, (event) => {
47-
queryClient.setQueryData<IThreadMessage[]>(currentQueryKey, (old) => {
48-
if (!old) {
49-
return old;
59+
mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => {
60+
const index = messages.findIndex((m) => m._id === event._id);
61+
if (index !== -1) {
62+
messages.splice(index, 1);
5063
}
51-
return old.filter((m) => m._id !== event._id);
5264
});
5365
});
5466

5567
const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom(`${roomId}/deleteMessageBulk`, (bulkParams) => {
5668
const matchDeleteCriteria = createDeleteCriteria(bulkParams);
5769

58-
queryClient.setQueryData<IThreadMessage[]>(currentQueryKey, (old) => {
59-
if (!old) {
60-
return old;
61-
}
62-
70+
mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => {
6371
if (bulkParams.filesOnly) {
64-
return old.map((msg) => {
72+
for (let index = 0; index < messages.length; index++) {
73+
const msg = messages[index];
6574
if (matchDeleteCriteria(msg)) {
66-
return modifyMessageOnFilesDelete(msg, bulkParams.replaceFileAttachmentsWith);
75+
messages[index] = modifyMessageOnFilesDelete(msg, bulkParams.replaceFileAttachmentsWith);
6776
}
68-
return msg;
69-
});
77+
}
78+
return;
7079
}
7180

72-
return old.filter((msg) => !matchDeleteCriteria(msg));
81+
const filtered = messages.filter((msg) => !matchDeleteCriteria(msg));
82+
messages.splice(0, messages.length, ...filtered);
7383
});
7484
});
7585

@@ -84,11 +94,9 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR
8494
return;
8595
}
8696

87-
queryClient.setQueryData<IThreadMessage[]>(currentQueryKey, (old) => {
88-
if (!old) {
89-
return old;
90-
}
91-
return markThreadMessagesAsRead(old, until);
97+
mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => {
98+
const updated = markThreadMessagesAsRead(messages, until);
99+
messages.splice(0, messages.length, ...updated);
92100
});
93101
});
94102

@@ -100,23 +108,49 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR
100108
};
101109
}, [tmid, roomId, queryClient, subscribeToRoomMessages, subscribeToNotifyRoom]);
102110

103-
return useQuery({
111+
return useInfiniteQuery({
104112
queryKey,
105-
queryFn: async () => {
106-
const cachedMessages = queryClient.getQueryData<IThreadMessage[]>(queryKey) || [];
113+
queryFn: async ({ pageParam: offset }) => {
114+
if (offset === 0) {
115+
void Promise.resolve(readThreads(tmid)).catch(() => undefined);
116+
}
107117

108-
const messages = await getThreadMessages({ tmid });
109-
const filtered = messages.filter(
110-
(msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true,
111-
);
118+
const cachedData = offset === 0 ? queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey) : undefined;
119+
const cachedMessages = cachedData?.pages[0]?.items ?? [];
112120

113-
const sorted = mergeThreadMessages(cachedMessages, filtered);
114-
if (unprocessedReadMessagesEvent.current) {
121+
const { messages, total } = await getThreadMessages({
122+
tmid,
123+
offset,
124+
count,
125+
sort: JSON.stringify({ ts: 1 }),
126+
});
127+
128+
let filtered = filterThreadMessages(messages, tmid);
129+
if (offset === 0) {
130+
filtered = mergeThreadMessages(cachedMessages, filtered);
131+
}
132+
133+
if (offset === 0 && unprocessedReadMessagesEvent.current) {
115134
const { until } = unprocessedReadMessagesEvent.current;
116135
unprocessedReadMessagesEvent.current = null;
117-
return processMessages(markThreadMessagesAsRead(sorted, until)) as Promise<Array<IThreadMessage>>;
136+
filtered = markThreadMessagesAsRead(filtered, until);
118137
}
119-
return processMessages(sorted) as Promise<Array<IThreadMessage>>;
138+
139+
const processed = (await processMessages(filtered)) as IThreadMessage[];
140+
141+
return {
142+
items: processed,
143+
itemCount: total,
144+
};
145+
},
146+
initialPageParam: 0,
147+
getNextPageParam: (lastPage, allPages) => {
148+
const loadedItemsCount = allPages.reduce((acc, page) => acc + page.items.length, 0);
149+
return loadedItemsCount < lastPage.itemCount ? loadedItemsCount : undefined;
120150
},
151+
select: ({ pages }) => ({
152+
messages: pages.flatMap((page) => page.items),
153+
itemCount: pages.at(-1)?.itemCount ?? 0,
154+
}),
121155
});
122156
};

0 commit comments

Comments
 (0)