Skip to content

Commit add1e18

Browse files
chore: loadaround and jump to message
1 parent 5270d11 commit add1e18

5 files changed

Lines changed: 139 additions & 31 deletions

File tree

apps/meteor/app/api/server/v1/chat.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,7 +1135,7 @@ const chatEndpoints = API.v1
11351135
},
11361136
},
11371137
async function action() {
1138-
const { tmid } = this.queryParams;
1138+
const { tmid, aroundId } = this.queryParams;
11391139
const { query, fields, sort } = await this.parseJsonQuery();
11401140
const { offset, count } = await getPaginationItems(this.queryParams);
11411141

@@ -1153,11 +1153,27 @@ const chatEndpoints = API.v1
11531153
if (!room || !user || !(await canAccessRoomAsync(room, user))) {
11541154
throw new Meteor.Error('error-not-allowed', 'Not Allowed');
11551155
}
1156+
1157+
let resolvedOffset = offset;
1158+
let resolvedSort = sort || { ts: 1 };
1159+
if (aroundId) {
1160+
resolvedSort = { ts: 1 };
1161+
if (aroundId === tmid) {
1162+
resolvedOffset = 0;
1163+
} else {
1164+
const target = await Messages.findOneById(aroundId, { projection: { ts: 1, tmid: 1 } });
1165+
if (target?.tmid === tmid && target.ts) {
1166+
const before = await Messages.countDocuments({ tmid, ts: { $lt: target.ts } });
1167+
resolvedOffset = Math.max(0, before - Math.floor(count / 2));
1168+
}
1169+
}
1170+
}
1171+
11561172
const { cursor, totalCount } = Messages.findPaginated(
11571173
{ ...query, tmid },
11581174
{
1159-
sort: sort || { ts: 1 },
1160-
skip: offset,
1175+
sort: resolvedSort,
1176+
skip: resolvedOffset,
11611177
limit: count,
11621178
projection: fields,
11631179
},
@@ -1168,7 +1184,7 @@ const chatEndpoints = API.v1
11681184
return API.v1.success({
11691185
messages,
11701186
count: messages.length,
1171-
offset,
1187+
offset: resolvedOffset,
11721188
total,
11731189
});
11741190
},

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

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,26 +65,29 @@ export const mutateThreadMessagesInfiniteData = (
6565
}
6666

6767
const items = old.pages.flatMap((page) => page.items);
68-
const lastPage = old.pages.at(-1) ?? { items: [], itemCount: 0 };
68+
const originalPageLengths = old.pages.map((page) => page.items.length);
69+
const oldTotal = old.pages.at(-1)?.itemCount ?? 0;
6970

7071
const beforeMutationItemsLength = items.length;
7172
mutation(items);
7273
const afterMutationItemsLength = items.length;
7374

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);
8075
const itemCountDelta = beforeMutationItemsLength - afterMutationItemsLength;
76+
const newTotal = Math.max(0, oldTotal - itemCountDelta);
77+
78+
const pages: ThreadMessagesPage[] = [];
79+
let cursor = 0;
80+
for (let pageIndex = 0; pageIndex < old.pages.length; pageIndex++) {
81+
const isLastPage = pageIndex === old.pages.length - 1;
82+
const take = isLastPage ? items.length - cursor : Math.min(originalPageLengths[pageIndex], items.length - cursor);
83+
const slice = items.slice(cursor, cursor + Math.max(0, take));
84+
cursor += slice.length;
85+
pages.push({ items: slice, itemCount: newTotal });
86+
}
8187

8288
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),
89+
pages,
90+
pageParams: old.pageParams,
8891
};
8992
});
9093
};

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

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { isTruthy } from '@rocket.chat/tools';
66
import { clientCallbacks, CustomVirtuaScrollbars } from '@rocket.chat/ui-client';
77
import { useSearchParameter, useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts';
88
import { differenceInSeconds } from 'date-fns';
9-
import { Fragment, useEffect, useMemo, useRef } from 'react';
9+
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
1010
import { useTranslation } from 'react-i18next';
1111
import type { VirtualizerHandle } from 'virtua';
1212
import { VList } from 'virtua';
@@ -63,7 +63,17 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
6363
const msgJumpParam = useSearchParameter('msg');
6464
const { bubbleRef, handleDateScroll, ...bubbleDate } = useDateScroll();
6565

66-
const { data, isLoading: loading, fetchNextPage, hasNextPage, isFetchingNextPage } = useThreadMessagesQuery(mainMessage._id);
66+
const {
67+
data,
68+
isLoading: loading,
69+
fetchNextPage,
70+
hasNextPage,
71+
isFetchingNextPage,
72+
fetchPreviousPage,
73+
hasPreviousPage,
74+
isFetchingPreviousPage,
75+
loadMessageAround,
76+
} = useThreadMessagesQuery(mainMessage._id);
6777
const messages = data?.messages ?? [];
6878

6979
const loadMoreMessages = useDebouncedCallback(
@@ -76,6 +86,16 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
7686
[hasNextPage, isFetchingNextPage, fetchNextPage],
7787
);
7888

89+
const loadPreviousMessages = useDebouncedCallback(
90+
() => {
91+
if (hasPreviousPage && !isFetchingPreviousPage) {
92+
void fetchPreviousPage();
93+
}
94+
},
95+
100,
96+
[hasPreviousPage, isFetchingPreviousPage, fetchPreviousPage],
97+
);
98+
7999
const room = useRoom();
80100
const uid = useUserId();
81101

@@ -90,6 +110,16 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
90110
const isAtBottom = useRef<boolean | null>(null);
91111
const prevItemsLengthRef = useRef(0);
92112

113+
// When older replies are prepended (loading previous pages), virtua must shift the
114+
// scroll position so the viewport stays anchored on the same message instead of
115+
// jumping. The flag is armed near the top in onScroll and reset after every commit,
116+
// so appends at the bottom (new messages) don't shift.
117+
// https://inokawa.github.io/virtua/?path=/story/advanced-chat--default
118+
const isPrepend = useRef(false);
119+
useLayoutEffect(() => {
120+
isPrepend.current = false;
121+
});
122+
93123
const { keepAtBottomRef, setKeepAtBottom } = useKeepAtBottom(isAtBottom);
94124
const messagesLength = messages.length;
95125
useEffect(() => {
@@ -101,9 +131,10 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
101131
}
102132
});
103133
}, [messagesLength, setKeepAtBottom, msgJumpParam]);
134+
const loadingWindowKeyRef = useRef<string | undefined>(undefined);
104135

105136
useEffect(() => {
106-
if (loading || isFetchingNextPage || !hasNextPage || !msgJumpParam) {
137+
if (loading || !msgJumpParam || isFetchingNextPage || isFetchingPreviousPage) {
107138
return;
108139
}
109140
if (msgJumpParam === mainMessage._id) {
@@ -112,8 +143,13 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
112143
if (messages.some((message) => message._id === msgJumpParam)) {
113144
return;
114145
}
115-
void fetchNextPage();
116-
}, [loading, isFetchingNextPage, hasNextPage, msgJumpParam, messages, mainMessage._id, fetchNextPage]);
146+
const windowKey = `${mainMessage._id}:${msgJumpParam}`;
147+
if (loadingWindowKeyRef.current === windowKey) {
148+
return;
149+
}
150+
loadingWindowKeyRef.current = windowKey;
151+
void loadMessageAround(msgJumpParam);
152+
}, [loading, isFetchingNextPage, isFetchingPreviousPage, msgJumpParam, messages, mainMessage._id, loadMessageAround]);
117153

118154
const mergedRefs = useMergedRefsV2(messageListRef, keepAtBottomRef);
119155

@@ -136,6 +172,7 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
136172

137173
useEffect(() => {
138174
lastThreadJumpKeyRef.current = undefined;
175+
loadingWindowKeyRef.current = undefined;
139176
prevItemsLengthRef.current = 0;
140177
}, [mainMessage._id]);
141178

@@ -239,15 +276,20 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
239276
<MessageListProvider>
240277
<VList
241278
ref={virtualizerRef}
279+
shift={isPrepend.current === true}
242280
style={{ height: '100%' }}
243281
aria-label={t('Thread_message_list')}
244-
aria-busy={loading || isFetchingNextPage}
282+
aria-busy={loading || isFetchingNextPage || isFetchingPreviousPage}
245283
role='list'
246284
keepMounted={keepMountedMessages}
247285
onScroll={(offset) => {
248286
const handle = virtualizerRef.current;
249287
if (!handle) return;
250288

289+
if (offset < 200 && hasPreviousPage) {
290+
isPrepend.current = true;
291+
}
292+
251293
// Copied from messageList, I'm unsure why this is necessary, but it seems to be needed to properly set the isAtBottom state
252294
if (handle.scrollSize >= handle.viewportSize) {
253295
isAtBottom.current = true;
@@ -281,6 +323,11 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
281323
firstUnread={firstUnread}
282324
system={system}
283325
/>
326+
{index === 0 && hasPreviousPage ? (
327+
<li className='load-more'>
328+
{isFetchingPreviousPage ? <LoadingMessagesIndicator /> : <InfiniteListAnchor loadMore={loadPreviousMessages} />}
329+
</li>
330+
) : null}
284331
</Fragment>
285332
);
286333
})

apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { isThreadMessage, type IMessage, type IRoom, type IThreadMainMessage, type IThreadMessage } from '@rocket.chat/core-typings';
22
import { useEndpoint, useMethod, useStream } from '@rocket.chat/ui-contexts';
33
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
4-
import { useEffect, useRef } from 'react';
4+
import { useCallback, useEffect, useRef } from 'react';
55

66
import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived';
77
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
@@ -112,7 +112,29 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR
112112
};
113113
}, [tmid, roomId, queryClient, subscribeToRoomMessages, subscribeToNotifyRoom]);
114114

115-
return useInfiniteQuery({
115+
const loadMessageAround = useCallback(
116+
async (messageId: string) => {
117+
const currentQueryKey = roomsQueryKeys.threadMessages(roomId, tmid);
118+
119+
const { messages, total, offset } = await getThreadMessages({
120+
tmid,
121+
aroundId: messageId,
122+
count,
123+
sort: JSON.stringify({ ts: 1 }),
124+
});
125+
126+
const filtered = filterThreadMessages(messages, tmid);
127+
const processed = (await processMessages(filtered)) as IThreadMessage[];
128+
129+
queryClient.setQueryData<ThreadMessagesInfiniteData>(currentQueryKey, {
130+
pages: [{ items: processed, itemCount: total }],
131+
pageParams: [offset],
132+
});
133+
},
134+
[queryClient, getThreadMessages, roomId, tmid, count],
135+
);
136+
137+
const query = useInfiniteQuery({
116138
queryKey,
117139
queryFn: async ({ pageParam: offset }) => {
118140
if (offset === 0) {
@@ -148,13 +170,27 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR
148170
};
149171
},
150172
initialPageParam: 0,
151-
getNextPageParam: (lastPage, allPages) => {
152-
const loadedItemsCount = allPages.reduce((acc, page) => acc + page.items.length, 0);
153-
return loadedItemsCount < lastPage.itemCount ? loadedItemsCount : undefined;
173+
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
174+
const next = lastPageParam + count;
175+
return next < lastPage.itemCount ? next : undefined;
176+
},
177+
getPreviousPageParam: (_firstPage, _allPages, firstPageParam) => {
178+
return firstPageParam > 0 ? Math.max(0, firstPageParam - count) : undefined;
179+
},
180+
select: ({ pages }) => {
181+
const byId = new Map<string, IThreadMessage>();
182+
for (const page of pages) {
183+
for (const item of page.items) {
184+
byId.set(item._id, item);
185+
}
186+
}
187+
const messages = Array.from(byId.values()).sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
188+
return {
189+
messages,
190+
itemCount: pages.at(-1)?.itemCount ?? 0,
191+
};
154192
},
155-
select: ({ pages }) => ({
156-
messages: pages.flatMap((page) => page.items),
157-
itemCount: pages.at(-1)?.itemCount ?? 0,
158-
}),
159193
});
194+
195+
return { ...query, loadMessageAround };
160196
};

packages/rest-typings/src/v1/chat.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,7 @@ export const isChatSyncThreadMessagesProps = ajvQuery.compile<ChatSyncThreadMess
683683

684684
type ChatGetThreadMessages = PaginatedRequest<{
685685
tmid: string;
686+
aroundId?: string;
686687
}>;
687688

688689
const ChatGetThreadMessagesSchema = {
@@ -692,6 +693,11 @@ const ChatGetThreadMessagesSchema = {
692693
type: 'string',
693694
minLength: 1,
694695
},
696+
aroundId: {
697+
type: 'string',
698+
minLength: 1,
699+
nullable: true,
700+
},
695701
count: {
696702
type: 'number',
697703
nullable: true,

0 commit comments

Comments
 (0)