snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived';
|
||||
import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi';
|
||||
import { Messages } from '../../../../../stores';
|
||||
|
||||
export const useGetMessageByID = (shouldStoreMessage: boolean = true) => {
|
||||
const getMessage = useEndpoint('GET', '/v1/chat.getMessage');
|
||||
const storeMessage = Messages.use((state) => state.store);
|
||||
|
||||
return useCallback(
|
||||
async (mid: IMessage['_id']) => {
|
||||
try {
|
||||
const { message: rawMessage } = await getMessage({ msgId: mid });
|
||||
const mappedMessage = mapMessageFromApi(rawMessage);
|
||||
const message = (await onClientMessageReceived(mappedMessage)) || mappedMessage;
|
||||
if (shouldStoreMessage) {
|
||||
storeMessage(message);
|
||||
}
|
||||
return message;
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'success' in error) {
|
||||
throw new Error('Message not found');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[getMessage, shouldStoreMessage, storeMessage],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { IThreadMainMessage } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useNormalizedThreadTitleHtml } from './useNormalizedThreadTitleHtml';
|
||||
import { filterMarkdown } from '../../../../../../app/markdown/lib/markdown';
|
||||
|
||||
jest.mock('../../../../../../app/markdown/lib/markdown', () => ({
|
||||
filterMarkdown: jest.fn((text: string) => text),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../lib/emoji/emojiParser', () => ({
|
||||
emojiParser: jest.fn((text: string) => text),
|
||||
}));
|
||||
|
||||
const mockedFilterMarkdown = jest.mocked(filterMarkdown);
|
||||
|
||||
const baseMessage = {
|
||||
_id: 'msg-id',
|
||||
rid: 'rid',
|
||||
ts: new Date(),
|
||||
u: { _id: 'uid', username: 'user' },
|
||||
_updatedAt: new Date(),
|
||||
tcount: 1,
|
||||
tlm: new Date(),
|
||||
replies: [],
|
||||
} as unknown as IThreadMainMessage;
|
||||
|
||||
describe('useNormalizedThreadTitleHtml', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return the "Message_removed" translation for a removed message without content', () => {
|
||||
const message = {
|
||||
...baseMessage,
|
||||
t: 'rm',
|
||||
msg: '',
|
||||
editedAt: new Date(),
|
||||
editedBy: { _id: 'uid', username: 'user' },
|
||||
} as unknown as IThreadMainMessage;
|
||||
|
||||
const { result } = renderHook(() => useNormalizedThreadTitleHtml(message), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe('Message_removed');
|
||||
expect(mockedFilterMarkdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the normalized message content for a regular message', () => {
|
||||
const message = { ...baseMessage, msg: 'Hello world' } as unknown as IThreadMainMessage;
|
||||
|
||||
const { result } = renderHook(() => useNormalizedThreadTitleHtml(message), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe('Hello world');
|
||||
expect(mockedFilterMarkdown).toHaveBeenCalledWith('Hello world');
|
||||
});
|
||||
|
||||
it('should render the message content when a `t: rm` message still has content', () => {
|
||||
const message = { ...baseMessage, t: 'rm', msg: 'Hello world' } as unknown as IThreadMainMessage;
|
||||
|
||||
const { result } = renderHook(() => useNormalizedThreadTitleHtml(message), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current).not.toBe('Message_removed');
|
||||
expect(result.current).toBe('Hello world');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { type IThreadMainMessage } from '@rocket.chat/core-typings';
|
||||
import { MessageTypes } from '@rocket.chat/message-types';
|
||||
import { escapeHTML } from '@rocket.chat/tools';
|
||||
import { useUser, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { filterMarkdown } from '../../../../../../app/markdown/lib/markdown';
|
||||
import { MentionsParser } from '../../../../../../app/mentions/lib/MentionsParser';
|
||||
import { emojiParser } from '../../../../../lib/emoji/emojiParser';
|
||||
|
||||
export const useNormalizedThreadTitleHtml = (mainMessage: IThreadMainMessage) => {
|
||||
const { t } = useTranslation();
|
||||
const me = useUser()?.username || '';
|
||||
const pattern = useSetting('UTF8_User_Names_Validation', '[0-9a-zA-Z-_.]+');
|
||||
const useRealName = useSetting('UI_Use_Real_Name', false);
|
||||
|
||||
return useMemo((): string => {
|
||||
const message = { ...mainMessage };
|
||||
const messageType = MessageTypes.getType(message);
|
||||
|
||||
if (message.msg) {
|
||||
const filteredMessage = filterMarkdown(escapeHTML(message.msg));
|
||||
if (!message.channels && !message.mentions) {
|
||||
return filteredMessage;
|
||||
}
|
||||
|
||||
const instance = new MentionsParser({
|
||||
pattern: () => pattern,
|
||||
useRealName: () => useRealName,
|
||||
me: () => me,
|
||||
userTemplate: ({ label }) => ` ${label} `,
|
||||
roomTemplate: ({ prefix, mention }) => `${prefix} ${mention} `,
|
||||
});
|
||||
const html = emojiParser(filteredMessage);
|
||||
return instance.parse({ ...message, msg: filteredMessage, html }).html ?? '';
|
||||
}
|
||||
|
||||
if (message.attachments) {
|
||||
const attachment = message.attachments.find((attachment) => attachment.title || attachment.description);
|
||||
|
||||
if (attachment?.description) {
|
||||
return escapeHTML(attachment.description);
|
||||
}
|
||||
|
||||
if (attachment?.title) {
|
||||
return escapeHTML(attachment.title);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.t && messageType) {
|
||||
return escapeHTML(messageType.text(t, message, { capitalize: true }));
|
||||
}
|
||||
|
||||
return '';
|
||||
}, [mainMessage, me, pattern, useRealName, t]);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { IMessage, IThreadMainMessage, MessageAttachment } from '@rocket.chat/core-typings';
|
||||
import { useStream } from '@rocket.chat/ui-contexts';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { useGetMessageByID } from './useGetMessageByID';
|
||||
import { withDebouncing } from '../../../../../../lib/utils/highOrderFunctions';
|
||||
import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived';
|
||||
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
|
||||
import { modifyMessageOnFilesDelete } from '../../../../../lib/utils/modifyMessageOnFilesDelete';
|
||||
import { createDeleteCriteria } from '../../../../../lib/utils/threadMessageUtils';
|
||||
import { useRoom } from '../../../contexts/RoomContext';
|
||||
|
||||
type RoomMessagesRidEvent = IMessage;
|
||||
|
||||
const useSubscribeToMessage = () => {
|
||||
const subscribeToRoomMessages = useStream('room-messages');
|
||||
const subscribeToNotifyRoom = useStream('notify-room');
|
||||
|
||||
return useCallback(
|
||||
(
|
||||
message: IMessage,
|
||||
{
|
||||
onMutate,
|
||||
onDelete,
|
||||
onFilesDelete,
|
||||
onMessagesRead,
|
||||
}: {
|
||||
onMutate?: (message: IMessage) => void | Promise<void>;
|
||||
onDelete?: () => void | Promise<void>;
|
||||
onFilesDelete?: (replaceFileAttachmentsWith?: MessageAttachment) => void | Promise<void>;
|
||||
onMessagesRead?: (event: { tmid?: string; until: Date }) => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
const unsubscribeFromRoomMessages = subscribeToRoomMessages(message.rid, (event: RoomMessagesRidEvent) => {
|
||||
if (message._id === event._id) onMutate?.(event);
|
||||
});
|
||||
|
||||
const unsubscribeFromDeleteMessage = subscribeToNotifyRoom(`${message.rid}/deleteMessage`, (event) => {
|
||||
if (message._id === event._id) onDelete?.();
|
||||
});
|
||||
|
||||
const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom(`${message.rid}/deleteMessageBulk`, (params) => {
|
||||
const matchDeleteCriteria = createDeleteCriteria(params);
|
||||
if (matchDeleteCriteria(message)) {
|
||||
if (params.filesOnly) {
|
||||
return onFilesDelete?.(params.replaceFileAttachmentsWith);
|
||||
}
|
||||
return onDelete?.();
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeFromMessagesRead = subscribeToNotifyRoom(`${message.rid}/messagesRead`, (event) => {
|
||||
onMessagesRead?.(event);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromRoomMessages();
|
||||
unsubscribeFromDeleteMessage();
|
||||
unsubscribeFromDeleteMessageBulk();
|
||||
unsubscribeFromMessagesRead();
|
||||
};
|
||||
},
|
||||
[subscribeToNotifyRoom, subscribeToRoomMessages],
|
||||
);
|
||||
};
|
||||
|
||||
export const useThreadMainMessageQuery = (
|
||||
tmid: IMessage['_id'],
|
||||
{ onDelete }: { onDelete?: () => void } = {},
|
||||
): UseQueryResult<IThreadMainMessage, Error> => {
|
||||
const room = useRoom();
|
||||
|
||||
const getMessage = useGetMessageByID(false);
|
||||
const subscribeToMessage = useSubscribeToMessage();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const unsubscribeRef = useRef<(() => void) | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unsubscribeRef.current?.();
|
||||
unsubscribeRef.current = undefined;
|
||||
};
|
||||
}, [tmid]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: roomsQueryKeys.threadMainMessage(room._id, tmid),
|
||||
|
||||
queryFn: async ({ queryKey }) => {
|
||||
const mainMessage = await getMessage(tmid);
|
||||
|
||||
if (!mainMessage) {
|
||||
throw new Error('Invalid main message');
|
||||
}
|
||||
|
||||
const debouncedInvalidate = withDebouncing({ wait: 10000 })(() => {
|
||||
queryClient.invalidateQueries({ queryKey, exact: true });
|
||||
});
|
||||
|
||||
unsubscribeRef.current =
|
||||
unsubscribeRef.current ||
|
||||
subscribeToMessage(mainMessage, {
|
||||
onMutate: async (message) => {
|
||||
const msg = await onClientMessageReceived(message);
|
||||
queryClient.setQueryData(queryKey, () => msg);
|
||||
debouncedInvalidate();
|
||||
},
|
||||
onDelete: () => {
|
||||
onDelete?.();
|
||||
queryClient.invalidateQueries({ queryKey, exact: true });
|
||||
},
|
||||
onFilesDelete: async (replaceFileAttachmentsWith?: MessageAttachment) => {
|
||||
const current = queryClient.getQueryData<IThreadMainMessage>(queryKey);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const updated = modifyMessageOnFilesDelete(current, replaceFileAttachmentsWith);
|
||||
|
||||
const msg = await onClientMessageReceived(updated);
|
||||
queryClient.setQueryData(queryKey, () => msg);
|
||||
debouncedInvalidate();
|
||||
},
|
||||
onMessagesRead: ({ tmid: eventTmid, until }) => {
|
||||
if (eventTmid) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<IThreadMainMessage>(queryKey, (old) => {
|
||||
if (!old?.unread) {
|
||||
return old;
|
||||
}
|
||||
|
||||
if (new Date(old.ts).getTime() <= new Date(until).getTime()) {
|
||||
const { unread: _, ...rest } = old;
|
||||
return rest as IThreadMainMessage;
|
||||
}
|
||||
|
||||
return old;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return mainMessage;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { IMessage, Serialized } from '@rocket.chat/core-typings';
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useThreadMessagesQuery } from './useThreadMessagesQuery';
|
||||
import { createFakeRoom } from '../../../../../../tests/mocks/data';
|
||||
|
||||
const room = createFakeRoom({ _id: 'room-id', t: 'c' });
|
||||
|
||||
jest.mock('../../../contexts/RoomContext', () => ({
|
||||
useRoom: () => room,
|
||||
}));
|
||||
|
||||
const TMID = 'thread-id';
|
||||
const TOTAL = 120;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const createSerializedReply = (index: number): Serialized<IMessage> => {
|
||||
const ts = new Date(Date.UTC(2026, 0, 1, 0, index)).toISOString();
|
||||
|
||||
return {
|
||||
_id: `reply-${index}`,
|
||||
rid: room._id,
|
||||
tmid: TMID,
|
||||
msg: `reply ${index}`,
|
||||
ts,
|
||||
_updatedAt: ts,
|
||||
u: { _id: 'user-id', username: 'user', name: 'User' },
|
||||
} as unknown as Serialized<IMessage>;
|
||||
};
|
||||
|
||||
const allReplies = Array.from({ length: TOTAL }, (_, index) => createSerializedReply(index));
|
||||
|
||||
const idsOf = (messages: { _id: string }[]) => messages.map(({ _id }) => _id);
|
||||
|
||||
const newestPageIds = idsOf(allReplies.slice(TOTAL - PAGE_SIZE));
|
||||
|
||||
type GetThreadMessagesParams = { tmid: string; offset?: number; count?: number; aroundId?: string; sort?: string };
|
||||
|
||||
const setup = ({ serverLimit = Infinity }: { serverLimit?: number } = {}) => {
|
||||
const getThreadMessages = jest.fn(({ offset = 0, count = PAGE_SIZE, aroundId }: GetThreadMessagesParams) => {
|
||||
const effectiveCount = Math.min(count, serverLimit);
|
||||
|
||||
if (aroundId) {
|
||||
const index = allReplies.findIndex(({ _id }) => _id === aroundId);
|
||||
const start = Math.max(0, index - Math.floor(effectiveCount / 2));
|
||||
|
||||
return { messages: allReplies.slice(start, start + effectiveCount), count: effectiveCount, offset: start, total: TOTAL };
|
||||
}
|
||||
|
||||
const end = TOTAL - offset;
|
||||
const start = Math.max(0, end - effectiveCount);
|
||||
|
||||
return { messages: allReplies.slice(start, end), count: effectiveCount, offset, total: TOTAL };
|
||||
});
|
||||
|
||||
const wrapper = mockAppRoot()
|
||||
.withJohnDoe()
|
||||
.withEndpoint('GET', '/v1/chat.getThreadMessages', getThreadMessages)
|
||||
.withEndpoint('POST', '/v1/chat.readThread', () => null)
|
||||
.build();
|
||||
|
||||
const { result } = renderHook(() => useThreadMessagesQuery(TMID), { wrapper });
|
||||
|
||||
return { result, getThreadMessages };
|
||||
};
|
||||
|
||||
const waitForLoaded = async (result: { current: ReturnType<typeof useThreadMessagesQuery> }) =>
|
||||
waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
describe('useThreadMessagesQuery', () => {
|
||||
it('reports no unloaded newer messages after the initial load', async () => {
|
||||
const { result } = setup();
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
expect(result.current.hasNextPage).toBe(false);
|
||||
expect(result.current.hasPreviousPage).toBe(true);
|
||||
expect(idsOf(result.current.data?.messages ?? [])).toEqual(newestPageIds);
|
||||
});
|
||||
|
||||
it('reports unloaded newer messages after jumping to an old reply', async () => {
|
||||
const { result } = setup();
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMessageAround('reply-10');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
expect(idsOf(result.current.data?.messages ?? [])).toEqual(idsOf(allReplies.slice(0, PAGE_SIZE)));
|
||||
});
|
||||
|
||||
describe('jumpToRecent', () => {
|
||||
it('refetches from the newest page and clears the unloaded-newer-messages flag', async () => {
|
||||
const { result, getThreadMessages } = setup();
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMessageAround('reply-10');
|
||||
});
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
|
||||
getThreadMessages.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.jumpToRecent();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(false));
|
||||
expect(getThreadMessages).toHaveBeenCalledWith(expect.objectContaining({ tmid: TMID, offset: 0 }));
|
||||
});
|
||||
|
||||
it('discards the detached window instead of merging it into the newest page', async () => {
|
||||
const { result } = setup();
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMessageAround('reply-10');
|
||||
});
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.jumpToRecent();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(false));
|
||||
expect(idsOf(result.current.data?.messages ?? [])).toEqual(newestPageIds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the server clamps the response size (e.g. "Max Record Amount")', () => {
|
||||
const SERVER_LIMIT = 20;
|
||||
const AROUND_INDEX = 60;
|
||||
|
||||
it('does not skip a batch of messages when loading the next page after jumping to an old reply', async () => {
|
||||
const { result } = setup({ serverLimit: SERVER_LIMIT });
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMessageAround(`reply-${AROUND_INDEX}`);
|
||||
});
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
|
||||
const idsBeforeNextPage = idsOf(result.current.data?.messages ?? []);
|
||||
const oldestLoadedIndex = allReplies.findIndex(({ _id }) => _id === idsBeforeNextPage[0]);
|
||||
const newestLoadedIndex = allReplies.findIndex(({ _id }) => _id === idsBeforeNextPage[idsBeforeNextPage.length - 1]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchNextPage();
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetchingNextPage).toBe(false));
|
||||
|
||||
// The newly-loaded batch must continue immediately after the previously loaded one, with no gap.
|
||||
const idsAfterNextPage = idsOf(result.current.data?.messages ?? []);
|
||||
expect(idsAfterNextPage).toEqual(idsOf(allReplies.slice(oldestLoadedIndex, newestLoadedIndex + 1 + SERVER_LIMIT)));
|
||||
});
|
||||
|
||||
it('reaches the newest message without gaps after repeatedly loading next pages', async () => {
|
||||
const { result } = setup({ serverLimit: SERVER_LIMIT });
|
||||
|
||||
await waitForLoaded(result);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMessageAround(`reply-${AROUND_INDEX}`);
|
||||
});
|
||||
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
|
||||
|
||||
const oldestLoadedIndex = allReplies.findIndex(({ _id }) => _id === (result.current.data?.messages ?? [])[0]._id);
|
||||
|
||||
while (result.current.hasNextPage) {
|
||||
await act(async () => {
|
||||
await result.current.fetchNextPage();
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetchingNextPage).toBe(false));
|
||||
}
|
||||
|
||||
expect(idsOf(result.current.data?.messages ?? [])).toEqual(idsOf(allReplies.slice(oldestLoadedIndex)));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { IRoom, IMessage, IThreadMainMessage, IThreadMessage, Serialized } from '@rocket.chat/core-typings';
|
||||
import { isThreadMessage } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint, 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 { getNumericConfig } 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';
|
||||
|
||||
const processMessages = async (messages: IMessage[]): Promise<IMessage[]> => {
|
||||
return Promise.all(messages.map((msg) => onClientMessageReceived(msg)));
|
||||
};
|
||||
|
||||
const filterThreadMessages = (messages: Serialized<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;
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = roomsQueryKeys.threadMessages(roomId, tmid);
|
||||
const getThreadMessages = useEndpoint('GET', '/v1/chat.getThreadMessages');
|
||||
// `chat.getThreadMessages` is a plain read, so marking the thread as read is an
|
||||
// explicit call — the DDP method it replaced did both server-side.
|
||||
const readThread = useEndpoint('POST', '/v1/chat.readThread');
|
||||
|
||||
const subscribeToRoomMessages = useStream('room-messages');
|
||||
const subscribeToNotifyRoom = useStream('notify-room');
|
||||
|
||||
const unprocessedReadMessagesEvent = useRef<{ tmid: string; until: Date } | null>(null);
|
||||
|
||||
const count = getNumericConfig('threadMessagesSize', 50);
|
||||
|
||||
useEffect(() => {
|
||||
const currentQueryKey = roomsQueryKeys.threadMessages(roomId, tmid);
|
||||
|
||||
const unsubscribeFromRoomMessages = subscribeToRoomMessages(roomId, async (event) => {
|
||||
if (event.tmid !== tmid || event._hidden === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const processed = await onClientMessageReceived(event);
|
||||
upsertThreadMessageInCache(processed, roomId, tmid, queryClient);
|
||||
});
|
||||
|
||||
const unsubscribeFromDeleteMessage = subscribeToNotifyRoom(`${roomId}/deleteMessage`, (event) => {
|
||||
mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => {
|
||||
const index = messages.findIndex((m) => m._id === event._id);
|
||||
if (index !== -1) {
|
||||
messages.splice(index, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const unsubscribeFromDeleteMessageBulk = subscribeToNotifyRoom(`${roomId}/deleteMessageBulk`, (bulkParams) => {
|
||||
const matchDeleteCriteria = createDeleteCriteria(bulkParams);
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => {
|
||||
if (bulkParams.filesOnly) {
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
const msg = messages[index];
|
||||
if (matchDeleteCriteria(msg)) {
|
||||
messages[index] = modifyMessageOnFilesDelete(msg, bulkParams.replaceFileAttachmentsWith);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = messages.filter((msg) => !matchDeleteCriteria(msg));
|
||||
messages.splice(0, messages.length, ...filtered);
|
||||
});
|
||||
});
|
||||
|
||||
const unsubscribeFromMessagesRead = subscribeToNotifyRoom(`${roomId}/messagesRead`, ({ tmid: eventTmid, until }) => {
|
||||
if (eventTmid && eventTmid !== tmid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPending = queryClient.getQueryState(currentQueryKey)?.fetchStatus === 'fetching';
|
||||
if (isPending) {
|
||||
unprocessedReadMessagesEvent.current = { tmid, until };
|
||||
return;
|
||||
}
|
||||
|
||||
mutateThreadMessagesInfiniteData(queryClient, currentQueryKey, (messages) => {
|
||||
const updated = markThreadMessagesAsRead(messages, until);
|
||||
messages.splice(0, messages.length, ...updated);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromRoomMessages();
|
||||
unsubscribeFromDeleteMessage();
|
||||
unsubscribeFromDeleteMessageBulk();
|
||||
unsubscribeFromMessagesRead();
|
||||
};
|
||||
}, [tmid, roomId, queryClient, subscribeToRoomMessages, subscribeToNotifyRoom]);
|
||||
|
||||
const loadMessageAround = useCallback(
|
||||
async (messageId: string) => {
|
||||
const currentQueryKey = roomsQueryKeys.threadMessages(roomId, tmid);
|
||||
|
||||
await queryClient.cancelQueries({ queryKey: currentQueryKey });
|
||||
|
||||
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 pageSize = Math.min(processed.length, count);
|
||||
const pageParam = Math.max(0, total - offset - pageSize);
|
||||
|
||||
queryClient.setQueryData<ThreadMessagesInfiniteData>(currentQueryKey, {
|
||||
pages: [{ items: processed, itemCount: total }],
|
||||
pageParams: [pageParam],
|
||||
});
|
||||
},
|
||||
[queryClient, getThreadMessages, roomId, tmid, count],
|
||||
);
|
||||
|
||||
const jumpToRecent = useCallback(async () => {
|
||||
await queryClient.resetQueries({ queryKey: roomsQueryKeys.threadMessages(roomId, tmid) });
|
||||
}, [queryClient, roomId, tmid]);
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam: offset }) => {
|
||||
if (offset === 0) {
|
||||
void Promise.resolve(readThread({ tmid })).catch(() => undefined);
|
||||
}
|
||||
|
||||
const cachedData = offset === 0 ? queryClient.getQueryData<ThreadMessagesInfiniteData>(queryKey) : undefined;
|
||||
const cachedMessages = cachedData?.pages.at(-1)?.items ?? [];
|
||||
|
||||
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?.tmid === tmid) {
|
||||
const { until } = unprocessedReadMessagesEvent.current;
|
||||
unprocessedReadMessagesEvent.current = null;
|
||||
filtered = markThreadMessagesAsRead(filtered, until);
|
||||
}
|
||||
|
||||
const processed = (await processMessages(filtered)) as IThreadMessage[];
|
||||
|
||||
return {
|
||||
items: processed,
|
||||
itemCount: total,
|
||||
};
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, _allPages, lastPageParam) => {
|
||||
if (lastPageParam <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const pageSize = Math.min(lastPage.items.length, count);
|
||||
if (pageSize <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(0, lastPageParam - pageSize);
|
||||
},
|
||||
getPreviousPageParam: (firstPage, _allPages, firstPageParam) => {
|
||||
const pageSize = Math.min(firstPage.items.length, count);
|
||||
if (pageSize <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const next = firstPageParam + pageSize;
|
||||
return next < firstPage.itemCount ? next : undefined;
|
||||
},
|
||||
select: ({ pages }) => {
|
||||
const byId = new Map<string, IThreadMessage>();
|
||||
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,
|
||||
};
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return { ...query, loadMessageAround, jumpToRecent };
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { IThreadMainMessage, IMessage, ISubscription } from '@rocket.chat/core-typings';
|
||||
import { escapeRegExp } from '@rocket.chat/tools';
|
||||
import { useEndpoint, useUserId } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useInfiniteMessageQueryUpdates } from '../../../../../hooks/useInfiniteMessageQueryUpdates';
|
||||
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
|
||||
import { getNumericConfig } from '../../../../../lib/utils/getConfig';
|
||||
import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi';
|
||||
|
||||
type ThreadsListOptions =
|
||||
| {
|
||||
rid: IMessage['rid'];
|
||||
text?: string;
|
||||
type: 'unread';
|
||||
tunread: ISubscription['tunread'];
|
||||
}
|
||||
| {
|
||||
rid: IMessage['rid'];
|
||||
text?: string;
|
||||
type: 'following';
|
||||
tunread?: never;
|
||||
}
|
||||
| {
|
||||
rid: IMessage['rid'];
|
||||
text?: string;
|
||||
type?: undefined;
|
||||
tunread?: never;
|
||||
};
|
||||
|
||||
export const useThreadsList = ({ rid, text, type, tunread }: ThreadsListOptions) => {
|
||||
const getThreadsList = useEndpoint('GET', '/v1/chat.getThreadsList');
|
||||
|
||||
const count = getNumericConfig('threadsListSize', 10);
|
||||
|
||||
const userId = useUserId();
|
||||
|
||||
useInfiniteMessageQueryUpdates({
|
||||
queryKey: roomsQueryKeys.threads(rid, { type, text }),
|
||||
roomId: rid,
|
||||
// Replicates the filtering done server-side
|
||||
filter: (message): message is IThreadMainMessage => {
|
||||
if (typeof message.tcount !== 'number') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (type === 'following') {
|
||||
if (!userId || !message.replies?.includes(userId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'unread') {
|
||||
if (!tunread?.includes(message._id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const regex = new RegExp(escapeRegExp(text), 'i');
|
||||
if (!regex.test(message.msg)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
// Replicates the sorting done server-side
|
||||
compare: (a, b) => (b.tlm ?? b.ts).getTime() - (a.tlm ?? a.ts).getTime(),
|
||||
});
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: roomsQueryKeys.threads(rid, { type, text }),
|
||||
queryFn: async ({ pageParam: offset }) => {
|
||||
const { threads, total } = await getThreadsList({
|
||||
rid,
|
||||
type,
|
||||
text,
|
||||
offset,
|
||||
count,
|
||||
});
|
||||
|
||||
return {
|
||||
items: threads.map(mapMessageFromApi) as IThreadMainMessage[],
|
||||
itemCount: total,
|
||||
};
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
// FIXME: This is an estimation, as threads can be created or removed while paginating
|
||||
// Ideally, the server should return the next offset to use or the pagination should be done using "createdAt" or "updatedAt"
|
||||
const loadedItemsCount = allPages.reduce((acc, page) => acc + page.items.length, 0);
|
||||
return loadedItemsCount < lastPage.itemCount ? loadedItemsCount : undefined;
|
||||
},
|
||||
select: ({ pages }) => ({
|
||||
items: pages.flatMap((page) => page.items),
|
||||
itemCount: pages.at(-1)?.itemCount ?? 0,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { UseMutationOptions, UseMutationResult } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { roomsQueryKeys } from '../../../../../lib/queryKeys';
|
||||
|
||||
// TODO: its core should be moved to the ChatContext
|
||||
|
||||
type UseToggleFollowingThreadMutationVariables = {
|
||||
rid: IMessage['rid'];
|
||||
tmid: IMessage['_id'];
|
||||
follow: boolean;
|
||||
};
|
||||
|
||||
export const useToggleFollowingThreadMutation = (
|
||||
options?: Omit<UseMutationOptions<void, Error, UseToggleFollowingThreadMutationVariables>, 'mutationFn'>,
|
||||
): UseMutationResult<void, Error, UseToggleFollowingThreadMutationVariables> => {
|
||||
const followMessage = useEndpoint('POST', '/v1/chat.followMessage');
|
||||
const unfollowMessage = useEndpoint('POST', '/v1/chat.unfollowMessage');
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ tmid, follow }) => {
|
||||
if (follow) {
|
||||
await followMessage({ mid: tmid });
|
||||
return;
|
||||
}
|
||||
|
||||
await unfollowMessage({ mid: tmid });
|
||||
},
|
||||
...options,
|
||||
onSuccess: async (data, variables, context) => {
|
||||
await queryClient.invalidateQueries({ queryKey: roomsQueryKeys.threads(variables.rid) });
|
||||
await queryClient.invalidateQueries({ queryKey: roomsQueryKeys.message(variables.rid, variables.tmid) });
|
||||
return options?.onSuccess?.(data, variables, context);
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user