From 0aa7cf52682ecf00c36eb038b78df783a0ec632f Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 16 Jun 2026 16:43:41 -0300 Subject: [PATCH 1/6] feat(chat): add room-type-agnostic chat.history endpoint Add a new `GET /v1/chat.history` REST endpoint that returns a room's message history by `roomId` regardless of room type (channel, private group or DM), mirroring the behavior of `channels.history`, `groups.history` and `im.history` without requiring the caller to know the room type beforehand. It reuses `getChannelHistory`, which resolves access via `Authorization.canReadRoom`, so the same `{ rid, ts }`-indexed range query is used for any room. This provides a performant REST replacement for the deprecated `loadMissedMessages` DDP method, whose deprecation notice now points to `/v1/chat.history`. --- .changeset/chat-history-endpoint.md | 6 ++ apps/meteor/app/api/server/v1/chat.ts | 53 +++++++++++ .../server/methods/loadMissedMessages.ts | 2 +- apps/meteor/tests/end-to-end/api/chat.ts | 92 +++++++++++++++++++ packages/rest-typings/src/v1/chat.ts | 65 +++++++++++++ 5 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 .changeset/chat-history-endpoint.md diff --git a/.changeset/chat-history-endpoint.md b/.changeset/chat-history-endpoint.md new file mode 100644 index 0000000000000..bc57c937098f6 --- /dev/null +++ b/.changeset/chat-history-endpoint.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/meteor': minor +'@rocket.chat/rest-typings': minor +--- + +Adds a new `chat.history` REST endpoint that fetches a room's message history by `roomId` regardless of room type (channel, private group or DM), mirroring the behavior of `channels.history`/`groups.history`/`im.history` without requiring the caller to know the room type beforehand diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 62378bd436e7a..69d49f57aa86b 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -17,6 +17,7 @@ import { isChatIgnoreUserProps, isChatGetPinnedMessagesProps, isChatGetMentionedMessagesProps, + isChatHistoryProps, isChatReactProps, isChatGetDeletedMessagesProps, isChatSyncThreadsListProps, @@ -25,6 +26,7 @@ import { isChatGetStarredMessagesProps, isChatGetDiscussionsProps, validateBadRequestErrorResponse, + validateForbiddenErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; @@ -39,6 +41,7 @@ import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { deleteMessageValidatingPermission } from '../../../lib/server/functions/deleteMessage'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; +import { getChannelHistory } from '../../../lib/server/methods/getChannelHistory'; import { getSingleMessage } from '../../../lib/server/methods/getSingleMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage'; @@ -685,6 +688,56 @@ const chatEndpoints = API.v1 }); }, ) + .get( + 'chat.history', + { + authRequired: true, + query: isChatHistoryProps, + response: { + 200: ajv.compile<{ + messages: IMessage[]; + firstUnread?: IMessage; + unreadNotLoaded?: number; + }>({ + type: 'object', + properties: { + messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, + firstUnread: { $ref: '#/components/schemas/IMessage' }, + unreadNotLoaded: { type: 'number' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['messages', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + 403: validateForbiddenErrorResponse, + }, + }, + async function action() { + const { roomId, latest, oldest, inclusive, unreads, showThreadMessages } = this.queryParams; + + const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); + + const result = await getChannelHistory({ + rid: roomId, + fromUserId: this.userId, + latest: latest ? new Date(latest) : new Date(), + oldest: oldest ? new Date(oldest) : undefined, + inclusive: inclusive === 'true', + offset, + count, + unreads: unreads === 'true', + showThreadMessages: showThreadMessages === 'true', + }); + + if (!result) { + return API.v1.forbidden(); + } + + return API.v1.success('messages' in result ? result : { messages: result }); + }, + ) .get( 'chat.getMessage', { diff --git a/apps/meteor/server/methods/loadMissedMessages.ts b/apps/meteor/server/methods/loadMissedMessages.ts index 30f45f5fcefca..32e3ba05adeaa 100644 --- a/apps/meteor/server/methods/loadMissedMessages.ts +++ b/apps/meteor/server/methods/loadMissedMessages.ts @@ -16,7 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async loadMissedMessages(rid, start) { - methodDeprecationLogger.method('loadMissedMessages', '9.0.0', '/v1/chat.syncMessages'); + methodDeprecationLogger.method('loadMissedMessages', '9.0.0', '/v1/chat.history'); check(rid, String); check(start, Date); diff --git a/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts index 1792426f0b18c..776e882c0385a 100644 --- a/apps/meteor/tests/end-to-end/api/chat.ts +++ b/apps/meteor/tests/end-to-end/api/chat.ts @@ -4096,6 +4096,98 @@ describe('[Chat]', () => { await deleteRoom({ type: 'c', roomId: newChannel._id }); }); }); + + describe('[/chat.history]', () => { + let testChannel: IRoom; + let testGroup: IRoom; + + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `channel.test.history.${Date.now()}` })).body.channel; + testGroup = (await createRoom({ type: 'p', name: `group.test.history.${Date.now()}` })).body.group; + }); + + after(() => Promise.all([deleteRoom({ type: 'c', roomId: testChannel._id }), deleteRoom({ type: 'p', roomId: testGroup._id })])); + + it('should return an error when the required "roomId" parameter is not sent', (done) => { + void request + .get(api('chat.history')) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body.errorType).to.be.equal('error-invalid-params'); + expect(res.body.error).to.include(`must have required property 'roomId'`); + }) + .end(done); + }); + + it('should return forbidden when the user cannot access the room', (done) => { + void request + .get(api('chat.history')) + .set(credentials) + .query({ roomId: 'invalid-room' }) + .expect('Content-Type', 'application/json') + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }) + .end(done); + }); + + it('should return the message history of a channel by roomId', async () => { + const message = await sendSimpleMessage({ roomId: testChannel._id, text: 'channel history message' }); + + const response = await request.get(api('chat.history')).set(credentials).query({ roomId: testChannel._id }); + + expect(response.statusCode).to.equal(200); + expect(response.body).to.have.property('success', true); + expect(response.body).to.have.property('messages').and.to.be.an('array'); + expect(response.body.messages.map((msg: IMessage) => msg._id)).to.include(message.body.message._id); + }); + + it('should return the message history of a private group by the same endpoint (room type agnostic)', async () => { + const message = await sendSimpleMessage({ roomId: testGroup._id, text: 'group history message' }); + + const response = await request.get(api('chat.history')).set(credentials).query({ roomId: testGroup._id }); + + expect(response.statusCode).to.equal(200); + expect(response.body).to.have.property('success', true); + expect(response.body.messages.map((msg: IMessage) => msg._id)).to.include(message.body.message._id); + }); + + it('should only return messages created after the "oldest" timestamp', async () => { + const { channel } = (await createRoom({ type: 'c', name: `channel.test.history.oldest.${Date.now()}` })).body; + + const oldMessage = await sendSimpleMessage({ roomId: channel._id, text: 'old message' }); + const oldest = new Date().toISOString(); + const newMessage = await sendSimpleMessage({ roomId: channel._id, text: 'new message' }); + + const response = await request.get(api('chat.history')).set(credentials).query({ roomId: channel._id, oldest, inclusive: 'false' }); + + expect(response.statusCode).to.equal(200); + const ids = response.body.messages.map((msg: IMessage) => msg._id); + expect(ids).to.include(newMessage.body.message._id); + expect(ids).to.not.include(oldMessage.body.message._id); + + await deleteRoom({ type: 'c', roomId: channel._id }); + }); + + it('should respect the "count" parameter', async () => { + const { channel } = (await createRoom({ type: 'c', name: `channel.test.history.count.${Date.now()}` })).body; + + await sendSimpleMessage({ roomId: channel._id, text: 'message 1' }); + await sendSimpleMessage({ roomId: channel._id, text: 'message 2' }); + await sendSimpleMessage({ roomId: channel._id, text: 'message 3' }); + + const response = await request.get(api('chat.history')).set(credentials).query({ roomId: channel._id, count: 2 }); + + expect(response.statusCode).to.equal(200); + expect(response.body.messages).to.have.lengthOf(2); + + await deleteRoom({ type: 'c', roomId: channel._id }); + }); + }); }); describe('Threads', () => { diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 575bf87ecadb8..668f3f8ec98e7 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -646,6 +646,64 @@ const ChatSyncMessagesSchema = { export const isChatSyncMessagesProps = ajvQuery.compile(ChatSyncMessagesSchema); +type ChatHistory = { + roomId: IRoom['_id']; + latest?: string; + oldest?: string; + inclusive?: 'true' | 'false'; + count?: number; + offset?: number; + unreads?: 'true' | 'false'; + showThreadMessages?: 'true' | 'false'; +}; + +const ChatHistorySchema = { + type: 'object', + properties: { + roomId: { + type: 'string', + minLength: 1, + }, + latest: { + type: 'string', + minLength: 1, + nullable: true, + }, + oldest: { + type: 'string', + minLength: 1, + nullable: true, + }, + inclusive: { + type: 'string', + enum: ['true', 'false'], + nullable: true, + }, + count: { + type: 'number', + nullable: true, + }, + offset: { + type: 'number', + nullable: true, + }, + unreads: { + type: 'string', + enum: ['true', 'false'], + nullable: true, + }, + showThreadMessages: { + type: 'string', + enum: ['true', 'false'], + nullable: true, + }, + }, + required: ['roomId'], + additionalProperties: false, +}; + +export const isChatHistoryProps = ajvQuery.compile(ChatHistorySchema); + type ChatSyncThreadMessages = PaginatedRequest<{ tmid: string; updatedSince: string; @@ -990,6 +1048,13 @@ export type ChatEndpoints = { }; }; }; + '/v1/chat.history': { + GET: (params: ChatHistory) => { + messages: IMessage[]; + firstUnread?: IMessage; + unreadNotLoaded?: number; + }; + }; '/v1/chat.postMessage': { POST: (params: ChatPostMessage) => { ts: number; From daa22720b714b74685dc17b4507f063f13599aef Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Tue, 16 Jun 2026 16:43:41 -0300 Subject: [PATCH 2/6] refactor(client): load missed messages via chat.history REST endpoint Replace the `chat.syncMessages` REST call in the reconnect hook with the new `GET /v1/chat.history` endpoint. On reconnection the hook now fetches only messages created after the newest loaded message's timestamp (`ts`) via a single indexed range query, instead of scanning `_updatedAt` plus the trash collection. --- .../views/root/hooks/useLoadMissedMessages.ts | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts index 631a7c107e0a9..228b2f6d42bf8 100644 --- a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts +++ b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts @@ -22,33 +22,17 @@ const loadMissedMessages = async (rid: IRoom['_id']): Promise => { } try { - const { result } = await sdk.rest.get('/v1/chat.syncMessages', { + const { messages } = await sdk.rest.get('/v1/chat.history', { roomId: rid, - lastUpdate: lastMessage.ts.toISOString(), + oldest: lastMessage.ts.toISOString(), + inclusive: 'false', + count: 1000, }); - if (result?.updated?.length) { + if (messages.length) { const subscription = Subscriptions.state.find((record) => record.rid === rid); - // `/v1/chat.syncMessages` returns everything changed since `lastUpdate` by - // `_updatedAt`, which includes edits to older messages. We only want to - // upsert messages that are genuinely new (created after our newest loaded - // message) or that are already loaded (so edits stay in sync), otherwise we - // would inject stale messages into the room history. - await Promise.all( - result.updated - .map(mapMessageFromApi) - .filter((msg) => msg.ts.getTime() > lastMessage.ts.getTime() || Messages.state.has(msg._id)) - .map((msg) => upsertMessage({ msg, subscription })), - ); + await Promise.all(messages.map((msg) => upsertMessage({ msg: mapMessageFromApi(msg), subscription }))); } - - // Drop messages that were deleted while the connection was down, but only if - // they are currently loaded. - result?.deleted?.forEach(({ _id }) => { - if (Messages.state.has(_id)) { - Messages.state.delete(_id); - } - }); } catch (error) { console.error('Error loading missed messages:', error); } From 992cb21f77021f48ef1d292eb7c98ef34e4a0e90 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 17 Jun 2026 18:35:55 -0300 Subject: [PATCH 3/6] revert(client): keep chat.syncMessages for reconnect catch-up chat.history replaces the deprecated loadMissedMessages DDP method only. The reconnect hook must keep chat.syncMessages so it still reconciles edits and deletions made while offline. --- .../views/root/hooks/useLoadMissedMessages.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts index 228b2f6d42bf8..631a7c107e0a9 100644 --- a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts +++ b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts @@ -22,17 +22,33 @@ const loadMissedMessages = async (rid: IRoom['_id']): Promise => { } try { - const { messages } = await sdk.rest.get('/v1/chat.history', { + const { result } = await sdk.rest.get('/v1/chat.syncMessages', { roomId: rid, - oldest: lastMessage.ts.toISOString(), - inclusive: 'false', - count: 1000, + lastUpdate: lastMessage.ts.toISOString(), }); - if (messages.length) { + if (result?.updated?.length) { const subscription = Subscriptions.state.find((record) => record.rid === rid); - await Promise.all(messages.map((msg) => upsertMessage({ msg: mapMessageFromApi(msg), subscription }))); + // `/v1/chat.syncMessages` returns everything changed since `lastUpdate` by + // `_updatedAt`, which includes edits to older messages. We only want to + // upsert messages that are genuinely new (created after our newest loaded + // message) or that are already loaded (so edits stay in sync), otherwise we + // would inject stale messages into the room history. + await Promise.all( + result.updated + .map(mapMessageFromApi) + .filter((msg) => msg.ts.getTime() > lastMessage.ts.getTime() || Messages.state.has(msg._id)) + .map((msg) => upsertMessage({ msg, subscription })), + ); } + + // Drop messages that were deleted while the connection was down, but only if + // they are currently loaded. + result?.deleted?.forEach(({ _id }) => { + if (Messages.state.has(_id)) { + Messages.state.delete(_id); + } + }); } catch (error) { console.error('Error loading missed messages:', error); } From 3e3dcf5e444a9c160840d2a55d66fae81dbd5457 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 17 Jun 2026 18:38:26 -0300 Subject: [PATCH 4/6] refactor(client): load missed messages via chat.history, paginated Reconnect catch-up now uses /v1/chat.history. Page through with offset so the server-side count clamp (API_Upper_Count_Limit) cannot silently drop missed messages when many were sent while offline. --- .../views/root/hooks/useLoadMissedMessages.ts | 45 +++++++++---------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts index 631a7c107e0a9..a266c8d96af95 100644 --- a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts +++ b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts @@ -22,33 +22,30 @@ const loadMissedMessages = async (rid: IRoom['_id']): Promise => { } try { - const { result } = await sdk.rest.get('/v1/chat.syncMessages', { - roomId: rid, - lastUpdate: lastMessage.ts.toISOString(), - }); + const subscription = Subscriptions.state.find((record) => record.rid === rid); + const oldest = lastMessage.ts.toISOString(); - if (result?.updated?.length) { - const subscription = Subscriptions.state.find((record) => record.rid === rid); - // `/v1/chat.syncMessages` returns everything changed since `lastUpdate` by - // `_updatedAt`, which includes edits to older messages. We only want to - // upsert messages that are genuinely new (created after our newest loaded - // message) or that are already loaded (so edits stay in sync), otherwise we - // would inject stale messages into the room history. - await Promise.all( - result.updated - .map(mapMessageFromApi) - .filter((msg) => msg.ts.getTime() > lastMessage.ts.getTime() || Messages.state.has(msg._id)) - .map((msg) => upsertMessage({ msg, subscription })), - ); - } + // `/v1/chat.history` clamps `count` to the server's `API_Upper_Count_Limit`, + // so a single request can silently drop missed messages when many were sent + // while offline. Page through with `offset` until a short page is returned. + const pageSize = 100; + for (let offset = 0; ; offset += pageSize) { + const { messages } = await sdk.rest.get('/v1/chat.history', { + roomId: rid, + oldest, + inclusive: 'false', + count: pageSize, + offset, + }); - // Drop messages that were deleted while the connection was down, but only if - // they are currently loaded. - result?.deleted?.forEach(({ _id }) => { - if (Messages.state.has(_id)) { - Messages.state.delete(_id); + if (messages.length) { + await Promise.all(messages.map((msg) => upsertMessage({ msg: mapMessageFromApi(msg), subscription }))); } - }); + + if (messages.length < pageSize) { + break; + } + } } catch (error) { console.error('Error loading missed messages:', error); } From ef097a5a758ab9c4265ee700c3b73de7702d15a7 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 17 Jun 2026 19:47:23 -0300 Subject: [PATCH 5/6] revert: undo chat.history endpoint, restore loadMissedMessages method Reverts the loadMissedMessages deprecation added in #40711. Removes the new chat.history REST endpoint (types, tests) and points the reconnect catch-up back at the loadMissedMessages DDP method. chat.syncMessages would be the right long-term target (it also reconciles edits/deletions on reconnect), but its _updatedAt + trash-collection query is currently too slow for this path; left a TODO to optimize that query before migrating. --- .changeset/chat-history-endpoint.md | 6 -- .../revert-loadmissedmessages-deprecation.md | 5 + apps/meteor/app/api/server/v1/chat.ts | 53 ----------- .../views/root/hooks/useLoadMissedMessages.ts | 34 ++----- .../server/methods/loadMissedMessages.ts | 2 - apps/meteor/tests/end-to-end/api/chat.ts | 92 ------------------- packages/rest-typings/src/v1/chat.ts | 65 ------------- 7 files changed, 14 insertions(+), 243 deletions(-) delete mode 100644 .changeset/chat-history-endpoint.md create mode 100644 .changeset/revert-loadmissedmessages-deprecation.md diff --git a/.changeset/chat-history-endpoint.md b/.changeset/chat-history-endpoint.md deleted file mode 100644 index bc57c937098f6..0000000000000 --- a/.changeset/chat-history-endpoint.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@rocket.chat/meteor': minor -'@rocket.chat/rest-typings': minor ---- - -Adds a new `chat.history` REST endpoint that fetches a room's message history by `roomId` regardless of room type (channel, private group or DM), mirroring the behavior of `channels.history`/`groups.history`/`im.history` without requiring the caller to know the room type beforehand diff --git a/.changeset/revert-loadmissedmessages-deprecation.md b/.changeset/revert-loadmissedmessages-deprecation.md new file mode 100644 index 0000000000000..c7ac08e5427bf --- /dev/null +++ b/.changeset/revert-loadmissedmessages-deprecation.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Reverted the `loadMissedMessages` deprecation introduced in #40711. The reconnect catch-up keeps using the `loadMissedMessages` DDP method instead of `chat.syncMessages`, since the `chat.syncMessages` query (by `_updatedAt` + trash collection) is currently too slow for this path. The deprecation notice was removed until the query is optimized. diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 69d49f57aa86b..62378bd436e7a 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -17,7 +17,6 @@ import { isChatIgnoreUserProps, isChatGetPinnedMessagesProps, isChatGetMentionedMessagesProps, - isChatHistoryProps, isChatReactProps, isChatGetDeletedMessagesProps, isChatSyncThreadsListProps, @@ -26,7 +25,6 @@ import { isChatGetStarredMessagesProps, isChatGetDiscussionsProps, validateBadRequestErrorResponse, - validateForbiddenErrorResponse, validateUnauthorizedErrorResponse, } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; @@ -41,7 +39,6 @@ import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { deleteMessageValidatingPermission } from '../../../lib/server/functions/deleteMessage'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; -import { getChannelHistory } from '../../../lib/server/methods/getChannelHistory'; import { getSingleMessage } from '../../../lib/server/methods/getSingleMessage'; import { executeSendMessage } from '../../../lib/server/methods/sendMessage'; import { executeUpdateMessage } from '../../../lib/server/methods/updateMessage'; @@ -688,56 +685,6 @@ const chatEndpoints = API.v1 }); }, ) - .get( - 'chat.history', - { - authRequired: true, - query: isChatHistoryProps, - response: { - 200: ajv.compile<{ - messages: IMessage[]; - firstUnread?: IMessage; - unreadNotLoaded?: number; - }>({ - type: 'object', - properties: { - messages: { type: 'array', items: { $ref: '#/components/schemas/IMessage' } }, - firstUnread: { $ref: '#/components/schemas/IMessage' }, - unreadNotLoaded: { type: 'number' }, - success: { type: 'boolean', enum: [true] }, - }, - required: ['messages', 'success'], - additionalProperties: false, - }), - 400: validateBadRequestErrorResponse, - 401: validateUnauthorizedErrorResponse, - 403: validateForbiddenErrorResponse, - }, - }, - async function action() { - const { roomId, latest, oldest, inclusive, unreads, showThreadMessages } = this.queryParams; - - const { count = 20, offset = 0 } = await getPaginationItems(this.queryParams); - - const result = await getChannelHistory({ - rid: roomId, - fromUserId: this.userId, - latest: latest ? new Date(latest) : new Date(), - oldest: oldest ? new Date(oldest) : undefined, - inclusive: inclusive === 'true', - offset, - count, - unreads: unreads === 'true', - showThreadMessages: showThreadMessages === 'true', - }); - - if (!result) { - return API.v1.forbidden(); - } - - return API.v1.success('messages' in result ? result : { messages: result }); - }, - ) .get( 'chat.getMessage', { diff --git a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts index a266c8d96af95..9578f57931673 100644 --- a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts +++ b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts @@ -3,8 +3,7 @@ import { useConnectionStatus } from '@rocket.chat/ui-contexts'; import { useEffect, useRef } from 'react'; import { LegacyRoomManager, upsertMessage } from '../../../../app/ui-utils/client'; -import { sdk } from '../../../../app/utils/client/lib/SDKClient'; -import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi'; +import { callWithErrorHandling } from '../../../lib/utils/callWithErrorHandling'; import { Messages, Subscriptions } from '../../../stores'; /** @@ -22,29 +21,14 @@ const loadMissedMessages = async (rid: IRoom['_id']): Promise => { } try { - const subscription = Subscriptions.state.find((record) => record.rid === rid); - const oldest = lastMessage.ts.toISOString(); - - // `/v1/chat.history` clamps `count` to the server's `API_Upper_Count_Limit`, - // so a single request can silently drop missed messages when many were sent - // while offline. Page through with `offset` until a short page is returned. - const pageSize = 100; - for (let offset = 0; ; offset += pageSize) { - const { messages } = await sdk.rest.get('/v1/chat.history', { - roomId: rid, - oldest, - inclusive: 'false', - count: pageSize, - offset, - }); - - if (messages.length) { - await Promise.all(messages.map((msg) => upsertMessage({ msg: mapMessageFromApi(msg), subscription }))); - } - - if (messages.length < pageSize) { - break; - } + // TODO(ddp-removal): this should move to `/v1/chat.syncMessages` so reconnect + // also reconciles edits/deletions made while offline, but that endpoint queries + // by `_updatedAt` (+ trash collection) and is currently too slow for this path. + // Evaluate/optimize the query before migrating; for now keep the DDP method. + const result = await callWithErrorHandling('loadMissedMessages', rid, lastMessage.ts); + if (result) { + const subscription = Subscriptions.state.find((record) => record.rid === rid); + await Promise.all(Array.from(result).map((msg) => upsertMessage({ msg, subscription }))); } } catch (error) { console.error('Error loading missed messages:', error); diff --git a/apps/meteor/server/methods/loadMissedMessages.ts b/apps/meteor/server/methods/loadMissedMessages.ts index 32e3ba05adeaa..4a4b535fec099 100644 --- a/apps/meteor/server/methods/loadMissedMessages.ts +++ b/apps/meteor/server/methods/loadMissedMessages.ts @@ -5,7 +5,6 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { canAccessRoomIdAsync } from '../../app/authorization/server/functions/canAccessRoom'; -import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -16,7 +15,6 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async loadMissedMessages(rid, start) { - methodDeprecationLogger.method('loadMissedMessages', '9.0.0', '/v1/chat.history'); check(rid, String); check(start, Date); diff --git a/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts index 776e882c0385a..1792426f0b18c 100644 --- a/apps/meteor/tests/end-to-end/api/chat.ts +++ b/apps/meteor/tests/end-to-end/api/chat.ts @@ -4096,98 +4096,6 @@ describe('[Chat]', () => { await deleteRoom({ type: 'c', roomId: newChannel._id }); }); }); - - describe('[/chat.history]', () => { - let testChannel: IRoom; - let testGroup: IRoom; - - before(async () => { - testChannel = (await createRoom({ type: 'c', name: `channel.test.history.${Date.now()}` })).body.channel; - testGroup = (await createRoom({ type: 'p', name: `group.test.history.${Date.now()}` })).body.group; - }); - - after(() => Promise.all([deleteRoom({ type: 'c', roomId: testChannel._id }), deleteRoom({ type: 'p', roomId: testGroup._id })])); - - it('should return an error when the required "roomId" parameter is not sent', (done) => { - void request - .get(api('chat.history')) - .set(credentials) - .expect('Content-Type', 'application/json') - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body.errorType).to.be.equal('error-invalid-params'); - expect(res.body.error).to.include(`must have required property 'roomId'`); - }) - .end(done); - }); - - it('should return forbidden when the user cannot access the room', (done) => { - void request - .get(api('chat.history')) - .set(credentials) - .query({ roomId: 'invalid-room' }) - .expect('Content-Type', 'application/json') - .expect(403) - .expect((res) => { - expect(res.body).to.have.property('success', false); - }) - .end(done); - }); - - it('should return the message history of a channel by roomId', async () => { - const message = await sendSimpleMessage({ roomId: testChannel._id, text: 'channel history message' }); - - const response = await request.get(api('chat.history')).set(credentials).query({ roomId: testChannel._id }); - - expect(response.statusCode).to.equal(200); - expect(response.body).to.have.property('success', true); - expect(response.body).to.have.property('messages').and.to.be.an('array'); - expect(response.body.messages.map((msg: IMessage) => msg._id)).to.include(message.body.message._id); - }); - - it('should return the message history of a private group by the same endpoint (room type agnostic)', async () => { - const message = await sendSimpleMessage({ roomId: testGroup._id, text: 'group history message' }); - - const response = await request.get(api('chat.history')).set(credentials).query({ roomId: testGroup._id }); - - expect(response.statusCode).to.equal(200); - expect(response.body).to.have.property('success', true); - expect(response.body.messages.map((msg: IMessage) => msg._id)).to.include(message.body.message._id); - }); - - it('should only return messages created after the "oldest" timestamp', async () => { - const { channel } = (await createRoom({ type: 'c', name: `channel.test.history.oldest.${Date.now()}` })).body; - - const oldMessage = await sendSimpleMessage({ roomId: channel._id, text: 'old message' }); - const oldest = new Date().toISOString(); - const newMessage = await sendSimpleMessage({ roomId: channel._id, text: 'new message' }); - - const response = await request.get(api('chat.history')).set(credentials).query({ roomId: channel._id, oldest, inclusive: 'false' }); - - expect(response.statusCode).to.equal(200); - const ids = response.body.messages.map((msg: IMessage) => msg._id); - expect(ids).to.include(newMessage.body.message._id); - expect(ids).to.not.include(oldMessage.body.message._id); - - await deleteRoom({ type: 'c', roomId: channel._id }); - }); - - it('should respect the "count" parameter', async () => { - const { channel } = (await createRoom({ type: 'c', name: `channel.test.history.count.${Date.now()}` })).body; - - await sendSimpleMessage({ roomId: channel._id, text: 'message 1' }); - await sendSimpleMessage({ roomId: channel._id, text: 'message 2' }); - await sendSimpleMessage({ roomId: channel._id, text: 'message 3' }); - - const response = await request.get(api('chat.history')).set(credentials).query({ roomId: channel._id, count: 2 }); - - expect(response.statusCode).to.equal(200); - expect(response.body.messages).to.have.lengthOf(2); - - await deleteRoom({ type: 'c', roomId: channel._id }); - }); - }); }); describe('Threads', () => { diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 668f3f8ec98e7..575bf87ecadb8 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -646,64 +646,6 @@ const ChatSyncMessagesSchema = { export const isChatSyncMessagesProps = ajvQuery.compile(ChatSyncMessagesSchema); -type ChatHistory = { - roomId: IRoom['_id']; - latest?: string; - oldest?: string; - inclusive?: 'true' | 'false'; - count?: number; - offset?: number; - unreads?: 'true' | 'false'; - showThreadMessages?: 'true' | 'false'; -}; - -const ChatHistorySchema = { - type: 'object', - properties: { - roomId: { - type: 'string', - minLength: 1, - }, - latest: { - type: 'string', - minLength: 1, - nullable: true, - }, - oldest: { - type: 'string', - minLength: 1, - nullable: true, - }, - inclusive: { - type: 'string', - enum: ['true', 'false'], - nullable: true, - }, - count: { - type: 'number', - nullable: true, - }, - offset: { - type: 'number', - nullable: true, - }, - unreads: { - type: 'string', - enum: ['true', 'false'], - nullable: true, - }, - showThreadMessages: { - type: 'string', - enum: ['true', 'false'], - nullable: true, - }, - }, - required: ['roomId'], - additionalProperties: false, -}; - -export const isChatHistoryProps = ajvQuery.compile(ChatHistorySchema); - type ChatSyncThreadMessages = PaginatedRequest<{ tmid: string; updatedSince: string; @@ -1048,13 +990,6 @@ export type ChatEndpoints = { }; }; }; - '/v1/chat.history': { - GET: (params: ChatHistory) => { - messages: IMessage[]; - firstUnread?: IMessage; - unreadNotLoaded?: number; - }; - }; '/v1/chat.postMessage': { POST: (params: ChatPostMessage) => { ts: number; From cacda99bc89fcb06f51fc9ddbb643201865a13a4 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 17 Jun 2026 19:48:12 -0300 Subject: [PATCH 6/6] chore: drop changeset for revert --- .changeset/revert-loadmissedmessages-deprecation.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/revert-loadmissedmessages-deprecation.md diff --git a/.changeset/revert-loadmissedmessages-deprecation.md b/.changeset/revert-loadmissedmessages-deprecation.md deleted file mode 100644 index c7ac08e5427bf..0000000000000 --- a/.changeset/revert-loadmissedmessages-deprecation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Reverted the `loadMissedMessages` deprecation introduced in #40711. The reconnect catch-up keeps using the `loadMissedMessages` DDP method instead of `chat.syncMessages`, since the `chat.syncMessages` query (by `_updatedAt` + trash collection) is currently too slow for this path. The deprecation notice was removed until the query is optimized.