Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions apps/meteor/app/api/server/v1/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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 } });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Include the same query filter when calculating the aroundId offset. Otherwise filtered thread-message requests can return a page that is not centered around the requested message.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/app/api/server/v1/chat.ts, line 1166:

<comment>Include the same query filter when calculating the `aroundId` offset. Otherwise filtered thread-message requests can return a page that is not centered around the requested message.</comment>

<file context>
@@ -1153,11 +1153,27 @@ const chatEndpoints = API.v1
+				} 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));
+					}
</file context>
Suggested change
const before = await Messages.countDocuments({ tmid, ts: { $lt: target.ts } });
const before = await Messages.countDocuments({ ...query, 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,
},
Expand All @@ -1168,7 +1184,7 @@ const chatEndpoints = API.v1
return API.v1.success({
messages,
count: messages.length,
offset,
offset: resolvedOffset,
total,
});
},
Expand Down
72 changes: 62 additions & 10 deletions apps/meteor/client/lib/utils/threadMessageUtils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -47,24 +47,76 @@ export const createDeleteCriteria = (params: NotifyRoomRidDeleteBulkEvent): ((me
return createPredicateFromFilter(query);
};

export type ThreadMessagesPage = {
items: IThreadMessage[];
itemCount: number;
};

export type ThreadMessagesInfiniteData = InfiniteData<ThreadMessagesPage, number>;

export const mutateThreadMessagesInfiniteData = (
client: QueryClient,
queryKey: readonly unknown[],
mutation: (messages: IThreadMessage[]) => void,
): void => {
client.setQueryData<ThreadMessagesInfiniteData>(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'],
tmid: IMessage['_id'],
client: QueryClient = defaultQueryClient,
): void => {
const queryKey = roomsQueryKeys.threadMessages(rid, tmid);
client.setQueryData<IMessage[]>(queryKey, (old) => {
if (!old) {
return [message];
}
const idx = old.findIndex((m) => m._id === message._id);

if (!client.getQueryData<ThreadMessagesInfiniteData>(queryKey)) {
client.setQueryData<ThreadMessagesInfiniteData>(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());

Comment on lines +112 to +117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Update every cached copy of the message, not just the first match. Infinite pages can overlap, and a later stale duplicate can overwrite the updated one in the selected thread messages.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/client/lib/utils/threadMessageUtils.ts, line 112:

<comment>Update every cached copy of the message, not just the first match. Infinite pages can overlap, and a later stale duplicate can overwrite the updated one in the selected thread messages.</comment>

<file context>
@@ -47,24 +47,76 @@ export const createDeleteCriteria = (params: NotifyRoomRidDeleteBulkEvent): ((me
+	}
+
+	mutateThreadMessagesInfiniteData(client, queryKey, (messages) => {
+		const idx = messages.findIndex((m) => m._id === message._id);
 		if (idx >= 0) {
-			const updated = [...old];
</file context>
Suggested change
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());
let updated = false;
for (let index = 0; index < messages.length; index++) {
if (messages[index]._id === message._id) {
messages[index] = message as IThreadMessage;
updated = true;
}
}
if (updated) {
return;
}

messages.push(message as IThreadMessage);
messages.sort((a, b) => new Date(a.ts).getTime() - new Date(b.ts).getTime());
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand All @@ -88,12 +123,50 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
}
});
}, [messagesLength, setKeepAtBottom, msgJumpParam]);
const loadingWindowKeyRef = useRef<string | undefined>(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);
Comment on lines +138 to +143

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle failed deep-link loads and allow retry.

loadMessageAround is called outside React Query’s error state; if it rejects, the promise is unhandled and loadingWindowKeyRef stays set, so this target will not be retried.

Proposed fix
 		if (loadingWindowKeyRef.current === windowKey) {
 			return;
 		}
 		loadingWindowKeyRef.current = windowKey;
-		void loadMessageAround(msgJumpParam);
+		void loadMessageAround(msgJumpParam).catch(() => {
+			if (loadingWindowKeyRef.current === windowKey) {
+				loadingWindowKeyRef.current = undefined;
+			}
+		});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const windowKey = `${mainMessage._id}:${msgJumpParam}`;
if (loadingWindowKeyRef.current === windowKey) {
return;
}
loadingWindowKeyRef.current = windowKey;
void loadMessageAround(msgJumpParam);
const windowKey = `${mainMessage._id}:${msgJumpParam}`;
if (loadingWindowKeyRef.current === windowKey) {
return;
}
loadingWindowKeyRef.current = windowKey;
void loadMessageAround(msgJumpParam).catch(() => {
if (loadingWindowKeyRef.current === windowKey) {
loadingWindowKeyRef.current = undefined;
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx`
around lines 146 - 151, The deep-link load in ThreadMessageList’s jump handling
can fail silently and leave loadingWindowKeyRef stuck, blocking retries. Update
the logic around loadMessageAround(msgJumpParam) so failures are caught and
handled (for example by resetting loadingWindowKeyRef.current when the promise
rejects) and ensure the load can be attempted again on subsequent jumps; use the
jump-loading block in ThreadMessageList and the loadMessageAround call as the
key spots to fix.

}, [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<string | undefined>(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) {
Expand All @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Jump-to-message is off by one when older thread pages exist. Account for the top loader child before calling scrollToIndex.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx, line 182:

<comment>Jump-to-message is off by one when older thread pages exist. Account for the top loader child before calling `scrollToIndex`.</comment>

<file context>
@@ -165,15 +176,19 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
+		if (replyIndex < 0) {
+			return -1;
+		}
+		return showMainMessage ? 1 + replyIndex : replyIndex;
+	}, [msgJumpParam, loading, mainMessage._id, messages, showMainMessage]);
 
</file context>
Suggested change
return showMainMessage ? 1 + replyIndex : replyIndex;
return 1 + replyIndex;

}, [msgJumpParam, loading, mainMessage._id, messages, showMainMessage]);

const lastThreadJumpKeyRef = useRef<string | undefined>(undefined);

useEffect(() => {
lastThreadJumpKeyRef.current = undefined;
loadingWindowKeyRef.current = undefined;
prevItemsLengthRef.current = 0;
initialScrollDoneRef.current = false;
}, [mainMessage._id]);

useEffect(() => {
Expand Down Expand Up @@ -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]);

Expand All @@ -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();
Expand Down Expand Up @@ -213,15 +293,20 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
<MessageListProvider>
<VList
ref={virtualizerRef}
shift={isPrepend}
style={{ height: '100%' }}
aria-label={t('Thread_message_list')}
aria-busy={loading}
aria-busy={loading || isFetchingNextPage || isFetchingPreviousPage}
role='list'
keepMounted={keepMountedMessages}
onScroll={(offset) => {
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;
Expand All @@ -236,7 +321,11 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
<li className='load-more'>
<LoadingMessagesIndicator />
</li>
) : (
) : null}
{!loading && hasPreviousPage ? (
<li className='load-more'>{isFetchingPreviousPage ? <LoadingMessagesIndicator /> : null}</li>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Top date tracking becomes off by one while previous pages exist because the loader occupies VList index 0. Subtract the top-loader offset before indexing items.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/client/views/room/contextualBar/Threads/components/ThreadMessageList.tsx, line 326:

<comment>Top date tracking becomes off by one while previous pages exist because the loader occupies VList index 0. Subtract the top-loader offset before indexing `items`.</comment>

<file context>
@@ -304,7 +321,11 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
-						) : (
+						) : null}
+						{!loading && hasPreviousPage ? (
+							<li className='load-more'>{isFetchingPreviousPage ? <LoadingMessagesIndicator /> : null}</li>
+						) : null}
+						{!loading &&
</file context>

) : null}
{!loading &&
items.map((message, index, { [index - 1]: previous }) => {
const sequential = isMessageSequential(message, previous, messageGroupingPeriod);
const newDay = isMessageNewDay(message, previous);
Expand All @@ -257,8 +346,12 @@ const ThreadMessageList = ({ mainMessage, shouldJumpToBottom, setShouldJumpToBot
/>
</Fragment>
);
})
)}
})}
{!loading && hasNextPage ? (
<li className='load-more'>
{isFetchingNextPage ? <LoadingMessagesIndicator /> : <InfiniteListAnchor loadMore={loadMoreMessages} />}
</li>
) : null}
</VList>
</MessageListProvider>
</CustomVirtuaScrollbars>
Expand Down
Loading
Loading