From d63aeb25de7ded24e09df6fd7c4149035dc6f05d Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 27 May 2026 12:43:14 -0300 Subject: [PATCH 01/16] chore: migrate loadMissedMessages caller to /v1/chat.syncMessages The hook now hits the REST endpoint with the room's last-message ts as `lastUpdate`, maps each result through mapMessageFromApi so the Messages store sees real Date objects, and upserts the batch. The DDP method gets a deprecation log pointing at the REST route. --- .changeset/ddp-migrate-loadmissedmessages.md | 5 ++++ apps/meteor/_build/main-prod/client-entry.js | 26 +++++++++++++++++++ apps/meteor/_build/main-prod/client-meteor.js | 21 +++++++++++++++ apps/meteor/_build/main-prod/client-rspack.js | 21 +++++++++++++++ apps/meteor/_build/main-prod/server-entry.js | 21 +++++++++++++++ apps/meteor/_build/main-prod/server-meteor.js | 21 +++++++++++++++ apps/meteor/_build/main-prod/server-rspack.js | 21 +++++++++++++++ apps/meteor/_build/test/client-entry.js | 19 ++++++++++++++ apps/meteor/_build/test/client-meteor.js | 20 ++++++++++++++ apps/meteor/_build/test/client-rspack.js | 19 ++++++++++++++ apps/meteor/_build/test/server-entry.js | 19 ++++++++++++++ apps/meteor/_build/test/server-meteor.js | 20 ++++++++++++++ apps/meteor/_build/test/server-rspack.js | 19 ++++++++++++++ .../views/root/hooks/useLoadMissedMessages.ts | 12 ++++++--- apps/meteor/rspack.config.js | 15 +++++++++++ .../server/methods/loadMissedMessages.ts | 2 ++ 16 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 .changeset/ddp-migrate-loadmissedmessages.md create mode 100644 apps/meteor/_build/main-prod/client-entry.js create mode 100644 apps/meteor/_build/main-prod/client-meteor.js create mode 100644 apps/meteor/_build/main-prod/client-rspack.js create mode 100644 apps/meteor/_build/main-prod/server-entry.js create mode 100644 apps/meteor/_build/main-prod/server-meteor.js create mode 100644 apps/meteor/_build/main-prod/server-rspack.js create mode 100644 apps/meteor/_build/test/client-entry.js create mode 100644 apps/meteor/_build/test/client-meteor.js create mode 100644 apps/meteor/_build/test/client-rspack.js create mode 100644 apps/meteor/_build/test/server-entry.js create mode 100644 apps/meteor/_build/test/server-meteor.js create mode 100644 apps/meteor/_build/test/server-rspack.js create mode 100644 apps/meteor/rspack.config.js diff --git a/.changeset/ddp-migrate-loadmissedmessages.md b/.changeset/ddp-migrate-loadmissedmessages.md new file mode 100644 index 0000000000000..bf185a048bda8 --- /dev/null +++ b/.changeset/ddp-migrate-loadmissedmessages.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Migrate the `loadMissedMessages` DDP caller to `GET /v1/chat.syncMessages`. The client maps the serialized response through `mapMessageFromApi` so dates land as `Date` objects in the local Messages store. The DDP method keeps a deprecation log pointing at the REST route until the 9.0.0 sweep removes it. diff --git a/apps/meteor/_build/main-prod/client-entry.js b/apps/meteor/_build/main-prod/client-entry.js new file mode 100644 index 0000000000000..53292a9adc9f7 --- /dev/null +++ b/apps/meteor/_build/main-prod/client-entry.js @@ -0,0 +1,26 @@ +/** + * @file client-entry.js + * @description Entry point for Rspack build process + * -------------------------------------------------------------------------- + * πŸ”Œ Rspack Client Entry (Production) + * -------------------------------------------------------------------------- + * β€’ [β–  client-entry.js ] ──▢ [ client-rspack.js ] ──▢ [ client-meteor.js ] + * + * This file is the entry point that Rspack uses to start the build process. + * It imports the module defined in `meteor.mainModule.client` inside package.json. + * From here, Rspack can trace the entire dependency graph of your application + * and generate the bundled output (`client-rspack.js`). + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Enables HMR */ +/* Link to πŸ”Œ Meteor Client Entry */ +import '../../client/main.ts'; + +if (module.hot) { + module.hot.accept(); +} diff --git a/apps/meteor/_build/main-prod/client-meteor.js b/apps/meteor/_build/main-prod/client-meteor.js new file mode 100644 index 0000000000000..3006471b1934b --- /dev/null +++ b/apps/meteor/_build/main-prod/client-meteor.js @@ -0,0 +1,21 @@ +/** + * @file client-meteor.js + * @description Meteor runtime file that imports the Rspack bundle + * -------------------------------------------------------------------------- + * β˜„οΈ Meteor Client App (Production) + * -------------------------------------------------------------------------- + * β€’ [ client-entry.js ] ──▢ [ client-rspack.js ] ──▢ [β–  client-meteor.js ] + * + * This file overrides the corresponding `meteor.mainModule.client` entry in + * package.json. Meteor loads it at runtime, and it imports the Rspack + * bundle (`client-rspack.js`) so the application executes using the build + * produced by Rspack. + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Link to ⚑ Rspack Client App */ +import './client-rspack.js'; diff --git a/apps/meteor/_build/main-prod/client-rspack.js b/apps/meteor/_build/main-prod/client-rspack.js new file mode 100644 index 0000000000000..081ac0cafb20e --- /dev/null +++ b/apps/meteor/_build/main-prod/client-rspack.js @@ -0,0 +1,21 @@ +/** + * @file client-rspack.js + * @description Bundled output generated by Rspack + * -------------------------------------------------------------------------- + * ⚑ Rspack Client App (Production) + * -------------------------------------------------------------------------- + * β€’ [ client-entry.js ] ──▢ [β–  client-rspack.js ] ──▢ [ client-meteor.js ] + * + * This file is the bundled output generated by Rspack. + * It contains all application code and assets combined into one build. + * It is not used directly, but will be imported by the Meteor main module + * file (`client-meteor.js`) so that Meteor runs the Rspack bundle. + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Link to ⚑ Rspack Client App */ +import './client-rspack.js'; diff --git a/apps/meteor/_build/main-prod/server-entry.js b/apps/meteor/_build/main-prod/server-entry.js new file mode 100644 index 0000000000000..15e383964b069 --- /dev/null +++ b/apps/meteor/_build/main-prod/server-entry.js @@ -0,0 +1,21 @@ +/** + * @file server-entry.js + * @description Entry point for Rspack build process + * -------------------------------------------------------------------------- + * πŸ”Œ Rspack Server Entry (Production) + * -------------------------------------------------------------------------- + * β€’ [β–  server-entry.js ] ──▢ [ server-rspack.js ] ──▢ [ server-meteor.js ] + * + * This file is the entry point that Rspack uses to start the build process. + * It imports the module defined in `meteor.mainModule.server` inside package.json. + * From here, Rspack can trace the entire dependency graph of your application + * and generate the bundled output (`server-rspack.js`). + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Link to πŸ”Œ Meteor Server Entry */ +import '../../server/main.ts'; diff --git a/apps/meteor/_build/main-prod/server-meteor.js b/apps/meteor/_build/main-prod/server-meteor.js new file mode 100644 index 0000000000000..cf126af3c273a --- /dev/null +++ b/apps/meteor/_build/main-prod/server-meteor.js @@ -0,0 +1,21 @@ +/** + * @file server-meteor.js + * @description Meteor runtime file that imports the Rspack bundle + * -------------------------------------------------------------------------- + * β˜„οΈ Meteor Server App (Production) + * -------------------------------------------------------------------------- + * β€’ [ server-entry.js ] ──▢ [ server-rspack.js ] ──▢ [β–  server-meteor.js ] + * + * This file overrides the corresponding `meteor.mainModule.server` entry in + * package.json. Meteor loads it at runtime, and it imports the Rspack + * bundle (`server-rspack.js`) so the application executes using the build + * produced by Rspack. + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Link to ⚑ Rspack Server App */ +import './server-rspack.js'; diff --git a/apps/meteor/_build/main-prod/server-rspack.js b/apps/meteor/_build/main-prod/server-rspack.js new file mode 100644 index 0000000000000..4cc0a4a604c62 --- /dev/null +++ b/apps/meteor/_build/main-prod/server-rspack.js @@ -0,0 +1,21 @@ +/** + * @file server-rspack.js + * @description Bundled output generated by Rspack + * -------------------------------------------------------------------------- + * ⚑ Rspack Server App (Production) + * -------------------------------------------------------------------------- + * β€’ [ server-entry.js ] ──▢ [β–  server-rspack.js ] ──▢ [ server-meteor.js ] + * + * This file is the bundled output generated by Rspack. + * It contains all application code and assets combined into one build. + * It is not used directly, but will be imported by the Meteor main module + * file (`server-meteor.js`) so that Meteor runs the Rspack bundle. + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Link to ⚑ Rspack Server App */ +import './server-rspack.js'; diff --git a/apps/meteor/_build/test/client-entry.js b/apps/meteor/_build/test/client-entry.js new file mode 100644 index 0000000000000..f7e816adaca4a --- /dev/null +++ b/apps/meteor/_build/test/client-entry.js @@ -0,0 +1,19 @@ +/** + * @file test-entry.js + * @description Entry point for Rspack test build process + * -------------------------------------------------------------------------- + * ⚑ Rspack Test Entry (Test) + * -------------------------------------------------------------------------- + * β€’ [β–  test-entry.js ] ──▢ [ test-rspack.js ] ──▢ [ test-meteor.js ] + * + * This file is the starting point for the Rspack test build. It imports your + * Meteor app's test modules so Rspack can resolve every dependency and + * generate the bundled output: `test-rspack.js`. + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Tests automatically imported */ diff --git a/apps/meteor/_build/test/client-meteor.js b/apps/meteor/_build/test/client-meteor.js new file mode 100644 index 0000000000000..02a1b461d33e7 --- /dev/null +++ b/apps/meteor/_build/test/client-meteor.js @@ -0,0 +1,20 @@ +/** + * @file test-meteor.js + * @description Meteor runtime file that imports the Rspack test bundle + * -------------------------------------------------------------------------- + * β˜„οΈ Meteor Test App (Test) + * -------------------------------------------------------------------------- + * β€’ [ test-entry.js ] ──▢ [ test-rspack.js ] ──▢ [β–  test-meteor.js ] + * + * Defined under `meteor.testModule` in package.json. Meteor loads this + * file at runtime to import the Rspack test bundle (`test-rspack.js`) and + * run your tests. + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Link to ⚑ Rspack Test App */ +import './client-rspack.js'; diff --git a/apps/meteor/_build/test/client-rspack.js b/apps/meteor/_build/test/client-rspack.js new file mode 100644 index 0000000000000..b3f914ab2ce79 --- /dev/null +++ b/apps/meteor/_build/test/client-rspack.js @@ -0,0 +1,19 @@ +/** + * @file test-rspack.js + * @description Bundled output generated by Rspack for tests + * -------------------------------------------------------------------------- + * ⚑ Rspack Test App (Test) + * -------------------------------------------------------------------------- + * β€’ [ test-entry.js ] ──▢ [β–  test-rspack.js ] ──▢ [ test-meteor.js ] + * + * This file is the bundle that Rspack outputs for tests. It contains all of + * your test code in one optimized file. Next step is loading this bundle via + * `test-meteor.js`. + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Code generated */ diff --git a/apps/meteor/_build/test/server-entry.js b/apps/meteor/_build/test/server-entry.js new file mode 100644 index 0000000000000..f7e816adaca4a --- /dev/null +++ b/apps/meteor/_build/test/server-entry.js @@ -0,0 +1,19 @@ +/** + * @file test-entry.js + * @description Entry point for Rspack test build process + * -------------------------------------------------------------------------- + * ⚑ Rspack Test Entry (Test) + * -------------------------------------------------------------------------- + * β€’ [β–  test-entry.js ] ──▢ [ test-rspack.js ] ──▢ [ test-meteor.js ] + * + * This file is the starting point for the Rspack test build. It imports your + * Meteor app's test modules so Rspack can resolve every dependency and + * generate the bundled output: `test-rspack.js`. + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Tests automatically imported */ diff --git a/apps/meteor/_build/test/server-meteor.js b/apps/meteor/_build/test/server-meteor.js new file mode 100644 index 0000000000000..3fe711f1b0280 --- /dev/null +++ b/apps/meteor/_build/test/server-meteor.js @@ -0,0 +1,20 @@ +/** + * @file test-meteor.js + * @description Meteor runtime file that imports the Rspack test bundle + * -------------------------------------------------------------------------- + * β˜„οΈ Meteor Test App (Test) + * -------------------------------------------------------------------------- + * β€’ [ test-entry.js ] ──▢ [ test-rspack.js ] ──▢ [β–  test-meteor.js ] + * + * Defined under `meteor.testModule` in package.json. Meteor loads this + * file at runtime to import the Rspack test bundle (`test-rspack.js`) and + * run your tests. + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Link to ⚑ Rspack Test App */ +import './server-rspack.js'; diff --git a/apps/meteor/_build/test/server-rspack.js b/apps/meteor/_build/test/server-rspack.js new file mode 100644 index 0000000000000..b3f914ab2ce79 --- /dev/null +++ b/apps/meteor/_build/test/server-rspack.js @@ -0,0 +1,19 @@ +/** + * @file test-rspack.js + * @description Bundled output generated by Rspack for tests + * -------------------------------------------------------------------------- + * ⚑ Rspack Test App (Test) + * -------------------------------------------------------------------------- + * β€’ [ test-entry.js ] ──▢ [β–  test-rspack.js ] ──▢ [ test-meteor.js ] + * + * This file is the bundle that Rspack outputs for tests. It contains all of + * your test code in one optimized file. Next step is loading this bundle via + * `test-meteor.js`. + * + * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. + * These files also act as a cache: they can be safely removed and will be + * regenerated on the next build. They should be ignored in IDE suggestions + * and version control. + */ + +/* Code generated */ diff --git a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts index 341a7a59c68dd..80c62719b749d 100644 --- a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts +++ b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts @@ -3,7 +3,8 @@ import { useConnectionStatus } from '@rocket.chat/ui-contexts'; import { useEffect, useRef } from 'react'; import { LegacyRoomManager, upsertMessage } from '../../../../app/ui-utils/client'; -import { callWithErrorHandling } from '../../../lib/utils/callWithErrorHandling'; +import { sdk } from '../../../../app/utils/client/lib/SDKClient'; +import { mapMessageFromApi } from '../../../lib/utils/mapMessageFromApi'; import { Messages, Subscriptions } from '../../../stores'; /** @@ -21,10 +22,13 @@ const loadMissedMessages = async (rid: IRoom['_id']): Promise => { } try { - const result = await callWithErrorHandling('loadMissedMessages', rid, lastMessage.ts); - if (result) { + const { result } = await sdk.rest.get('/v1/chat.syncMessages', { + roomId: rid, + lastUpdate: lastMessage.ts.toISOString(), + }); + if (result?.updated?.length) { const subscription = Subscriptions.state.find((record) => record.rid === rid); - await Promise.all(Array.from(result).map((msg) => upsertMessage({ msg, subscription }))); + await Promise.all(result.updated.map((msg) => upsertMessage({ msg: mapMessageFromApi(msg), subscription }))); } } catch (error) { console.error('Error loading missed messages:', error); diff --git a/apps/meteor/rspack.config.js b/apps/meteor/rspack.config.js new file mode 100644 index 0000000000000..5e3064a5dbddb --- /dev/null +++ b/apps/meteor/rspack.config.js @@ -0,0 +1,15 @@ +const { defineConfig } = require('@meteorjs/rspack'); + +/** + * Rspack configuration for Meteor projects. + * + * Provides typed flags on the `Meteor` object, such as: + * - `Meteor.isClient` / `Meteor.isServer` + * - `Meteor.isDevelopment` / `Meteor.isProduction` + * - …and other flags available + * + * Use these flags to adjust your build settings based on environment. + */ +module.exports = defineConfig((Meteor) => { + return {}; +}); diff --git a/apps/meteor/server/methods/loadMissedMessages.ts b/apps/meteor/server/methods/loadMissedMessages.ts index 4a4b535fec099..30f45f5fcefca 100644 --- a/apps/meteor/server/methods/loadMissedMessages.ts +++ b/apps/meteor/server/methods/loadMissedMessages.ts @@ -5,6 +5,7 @@ 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 @@ -15,6 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async loadMissedMessages(rid, start) { + methodDeprecationLogger.method('loadMissedMessages', '9.0.0', '/v1/chat.syncMessages'); check(rid, String); check(start, Date); From 58e938e86b06366d7a234aff1bdaec515b57829b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 27 May 2026 12:47:17 -0300 Subject: [PATCH 02/16] chore: migrate useJoinRoom (channel case) to /v1/channels.join Dispatches by room type. For `type === 'c'` the channel case hits the REST endpoint; other types (private groups, DMs, livechat) keep the DDP `joinRoom` method until /v1/* coverage catches up. --- apps/meteor/client/hooks/useJoinRoom.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/meteor/client/hooks/useJoinRoom.ts b/apps/meteor/client/hooks/useJoinRoom.ts index 7204d378ca36f..8ca8d449d8a8a 100644 --- a/apps/meteor/client/hooks/useJoinRoom.ts +++ b/apps/meteor/client/hooks/useJoinRoom.ts @@ -1,5 +1,5 @@ import type { IRoom } from '@rocket.chat/core-typings'; -import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { sdk } from '../../app/utils/client/lib/SDKClient'; @@ -13,10 +13,17 @@ type UseJoinRoomMutationFunctionProps = { export const useJoinRoom = () => { const queryClient = useQueryClient(); const dispatchToastMessage = useToastMessageDispatch(); + const joinChannel = useEndpoint('POST', '/v1/channels.join'); return useMutation({ mutationFn: async ({ rid, reference, type }: UseJoinRoomMutationFunctionProps) => { - await sdk.call('joinRoom', rid); + if (type === 'c') { + await joinChannel({ roomId: rid }); + } else { + // /v1/channels.join only finds public channels; fall back to DDP for + // other room types (private groups, DMs, livechat). + await sdk.call('joinRoom', rid); + } return { reference, type }; }, From a9fdd4034cd4bbaa8b63cfc62ebc317b942d03b3 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 27 May 2026 12:50:04 -0300 Subject: [PATCH 03/16] chore: drop local build artifacts mistakenly added (_build, rspack.config.js) --- apps/meteor/_build/main-prod/client-entry.js | 26 ------------------- apps/meteor/_build/main-prod/client-meteor.js | 21 --------------- apps/meteor/_build/main-prod/client-rspack.js | 21 --------------- apps/meteor/_build/main-prod/server-entry.js | 21 --------------- apps/meteor/_build/main-prod/server-meteor.js | 21 --------------- apps/meteor/_build/main-prod/server-rspack.js | 21 --------------- apps/meteor/_build/test/client-entry.js | 19 -------------- apps/meteor/_build/test/client-meteor.js | 20 -------------- apps/meteor/_build/test/client-rspack.js | 19 -------------- apps/meteor/_build/test/server-entry.js | 19 -------------- apps/meteor/_build/test/server-meteor.js | 20 -------------- apps/meteor/_build/test/server-rspack.js | 19 -------------- apps/meteor/rspack.config.js | 15 ----------- 13 files changed, 262 deletions(-) delete mode 100644 apps/meteor/_build/main-prod/client-entry.js delete mode 100644 apps/meteor/_build/main-prod/client-meteor.js delete mode 100644 apps/meteor/_build/main-prod/client-rspack.js delete mode 100644 apps/meteor/_build/main-prod/server-entry.js delete mode 100644 apps/meteor/_build/main-prod/server-meteor.js delete mode 100644 apps/meteor/_build/main-prod/server-rspack.js delete mode 100644 apps/meteor/_build/test/client-entry.js delete mode 100644 apps/meteor/_build/test/client-meteor.js delete mode 100644 apps/meteor/_build/test/client-rspack.js delete mode 100644 apps/meteor/_build/test/server-entry.js delete mode 100644 apps/meteor/_build/test/server-meteor.js delete mode 100644 apps/meteor/_build/test/server-rspack.js delete mode 100644 apps/meteor/rspack.config.js diff --git a/apps/meteor/_build/main-prod/client-entry.js b/apps/meteor/_build/main-prod/client-entry.js deleted file mode 100644 index 53292a9adc9f7..0000000000000 --- a/apps/meteor/_build/main-prod/client-entry.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @file client-entry.js - * @description Entry point for Rspack build process - * -------------------------------------------------------------------------- - * πŸ”Œ Rspack Client Entry (Production) - * -------------------------------------------------------------------------- - * β€’ [β–  client-entry.js ] ──▢ [ client-rspack.js ] ──▢ [ client-meteor.js ] - * - * This file is the entry point that Rspack uses to start the build process. - * It imports the module defined in `meteor.mainModule.client` inside package.json. - * From here, Rspack can trace the entire dependency graph of your application - * and generate the bundled output (`client-rspack.js`). - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Enables HMR */ -/* Link to πŸ”Œ Meteor Client Entry */ -import '../../client/main.ts'; - -if (module.hot) { - module.hot.accept(); -} diff --git a/apps/meteor/_build/main-prod/client-meteor.js b/apps/meteor/_build/main-prod/client-meteor.js deleted file mode 100644 index 3006471b1934b..0000000000000 --- a/apps/meteor/_build/main-prod/client-meteor.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @file client-meteor.js - * @description Meteor runtime file that imports the Rspack bundle - * -------------------------------------------------------------------------- - * β˜„οΈ Meteor Client App (Production) - * -------------------------------------------------------------------------- - * β€’ [ client-entry.js ] ──▢ [ client-rspack.js ] ──▢ [β–  client-meteor.js ] - * - * This file overrides the corresponding `meteor.mainModule.client` entry in - * package.json. Meteor loads it at runtime, and it imports the Rspack - * bundle (`client-rspack.js`) so the application executes using the build - * produced by Rspack. - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Link to ⚑ Rspack Client App */ -import './client-rspack.js'; diff --git a/apps/meteor/_build/main-prod/client-rspack.js b/apps/meteor/_build/main-prod/client-rspack.js deleted file mode 100644 index 081ac0cafb20e..0000000000000 --- a/apps/meteor/_build/main-prod/client-rspack.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @file client-rspack.js - * @description Bundled output generated by Rspack - * -------------------------------------------------------------------------- - * ⚑ Rspack Client App (Production) - * -------------------------------------------------------------------------- - * β€’ [ client-entry.js ] ──▢ [β–  client-rspack.js ] ──▢ [ client-meteor.js ] - * - * This file is the bundled output generated by Rspack. - * It contains all application code and assets combined into one build. - * It is not used directly, but will be imported by the Meteor main module - * file (`client-meteor.js`) so that Meteor runs the Rspack bundle. - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Link to ⚑ Rspack Client App */ -import './client-rspack.js'; diff --git a/apps/meteor/_build/main-prod/server-entry.js b/apps/meteor/_build/main-prod/server-entry.js deleted file mode 100644 index 15e383964b069..0000000000000 --- a/apps/meteor/_build/main-prod/server-entry.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @file server-entry.js - * @description Entry point for Rspack build process - * -------------------------------------------------------------------------- - * πŸ”Œ Rspack Server Entry (Production) - * -------------------------------------------------------------------------- - * β€’ [β–  server-entry.js ] ──▢ [ server-rspack.js ] ──▢ [ server-meteor.js ] - * - * This file is the entry point that Rspack uses to start the build process. - * It imports the module defined in `meteor.mainModule.server` inside package.json. - * From here, Rspack can trace the entire dependency graph of your application - * and generate the bundled output (`server-rspack.js`). - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Link to πŸ”Œ Meteor Server Entry */ -import '../../server/main.ts'; diff --git a/apps/meteor/_build/main-prod/server-meteor.js b/apps/meteor/_build/main-prod/server-meteor.js deleted file mode 100644 index cf126af3c273a..0000000000000 --- a/apps/meteor/_build/main-prod/server-meteor.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @file server-meteor.js - * @description Meteor runtime file that imports the Rspack bundle - * -------------------------------------------------------------------------- - * β˜„οΈ Meteor Server App (Production) - * -------------------------------------------------------------------------- - * β€’ [ server-entry.js ] ──▢ [ server-rspack.js ] ──▢ [β–  server-meteor.js ] - * - * This file overrides the corresponding `meteor.mainModule.server` entry in - * package.json. Meteor loads it at runtime, and it imports the Rspack - * bundle (`server-rspack.js`) so the application executes using the build - * produced by Rspack. - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Link to ⚑ Rspack Server App */ -import './server-rspack.js'; diff --git a/apps/meteor/_build/main-prod/server-rspack.js b/apps/meteor/_build/main-prod/server-rspack.js deleted file mode 100644 index 4cc0a4a604c62..0000000000000 --- a/apps/meteor/_build/main-prod/server-rspack.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @file server-rspack.js - * @description Bundled output generated by Rspack - * -------------------------------------------------------------------------- - * ⚑ Rspack Server App (Production) - * -------------------------------------------------------------------------- - * β€’ [ server-entry.js ] ──▢ [β–  server-rspack.js ] ──▢ [ server-meteor.js ] - * - * This file is the bundled output generated by Rspack. - * It contains all application code and assets combined into one build. - * It is not used directly, but will be imported by the Meteor main module - * file (`server-meteor.js`) so that Meteor runs the Rspack bundle. - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Link to ⚑ Rspack Server App */ -import './server-rspack.js'; diff --git a/apps/meteor/_build/test/client-entry.js b/apps/meteor/_build/test/client-entry.js deleted file mode 100644 index f7e816adaca4a..0000000000000 --- a/apps/meteor/_build/test/client-entry.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @file test-entry.js - * @description Entry point for Rspack test build process - * -------------------------------------------------------------------------- - * ⚑ Rspack Test Entry (Test) - * -------------------------------------------------------------------------- - * β€’ [β–  test-entry.js ] ──▢ [ test-rspack.js ] ──▢ [ test-meteor.js ] - * - * This file is the starting point for the Rspack test build. It imports your - * Meteor app's test modules so Rspack can resolve every dependency and - * generate the bundled output: `test-rspack.js`. - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Tests automatically imported */ diff --git a/apps/meteor/_build/test/client-meteor.js b/apps/meteor/_build/test/client-meteor.js deleted file mode 100644 index 02a1b461d33e7..0000000000000 --- a/apps/meteor/_build/test/client-meteor.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @file test-meteor.js - * @description Meteor runtime file that imports the Rspack test bundle - * -------------------------------------------------------------------------- - * β˜„οΈ Meteor Test App (Test) - * -------------------------------------------------------------------------- - * β€’ [ test-entry.js ] ──▢ [ test-rspack.js ] ──▢ [β–  test-meteor.js ] - * - * Defined under `meteor.testModule` in package.json. Meteor loads this - * file at runtime to import the Rspack test bundle (`test-rspack.js`) and - * run your tests. - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Link to ⚑ Rspack Test App */ -import './client-rspack.js'; diff --git a/apps/meteor/_build/test/client-rspack.js b/apps/meteor/_build/test/client-rspack.js deleted file mode 100644 index b3f914ab2ce79..0000000000000 --- a/apps/meteor/_build/test/client-rspack.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @file test-rspack.js - * @description Bundled output generated by Rspack for tests - * -------------------------------------------------------------------------- - * ⚑ Rspack Test App (Test) - * -------------------------------------------------------------------------- - * β€’ [ test-entry.js ] ──▢ [β–  test-rspack.js ] ──▢ [ test-meteor.js ] - * - * This file is the bundle that Rspack outputs for tests. It contains all of - * your test code in one optimized file. Next step is loading this bundle via - * `test-meteor.js`. - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Code generated */ diff --git a/apps/meteor/_build/test/server-entry.js b/apps/meteor/_build/test/server-entry.js deleted file mode 100644 index f7e816adaca4a..0000000000000 --- a/apps/meteor/_build/test/server-entry.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @file test-entry.js - * @description Entry point for Rspack test build process - * -------------------------------------------------------------------------- - * ⚑ Rspack Test Entry (Test) - * -------------------------------------------------------------------------- - * β€’ [β–  test-entry.js ] ──▢ [ test-rspack.js ] ──▢ [ test-meteor.js ] - * - * This file is the starting point for the Rspack test build. It imports your - * Meteor app's test modules so Rspack can resolve every dependency and - * generate the bundled output: `test-rspack.js`. - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Tests automatically imported */ diff --git a/apps/meteor/_build/test/server-meteor.js b/apps/meteor/_build/test/server-meteor.js deleted file mode 100644 index 3fe711f1b0280..0000000000000 --- a/apps/meteor/_build/test/server-meteor.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @file test-meteor.js - * @description Meteor runtime file that imports the Rspack test bundle - * -------------------------------------------------------------------------- - * β˜„οΈ Meteor Test App (Test) - * -------------------------------------------------------------------------- - * β€’ [ test-entry.js ] ──▢ [ test-rspack.js ] ──▢ [β–  test-meteor.js ] - * - * Defined under `meteor.testModule` in package.json. Meteor loads this - * file at runtime to import the Rspack test bundle (`test-rspack.js`) and - * run your tests. - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Link to ⚑ Rspack Test App */ -import './server-rspack.js'; diff --git a/apps/meteor/_build/test/server-rspack.js b/apps/meteor/_build/test/server-rspack.js deleted file mode 100644 index b3f914ab2ce79..0000000000000 --- a/apps/meteor/_build/test/server-rspack.js +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @file test-rspack.js - * @description Bundled output generated by Rspack for tests - * -------------------------------------------------------------------------- - * ⚑ Rspack Test App (Test) - * -------------------------------------------------------------------------- - * β€’ [ test-entry.js ] ──▢ [β–  test-rspack.js ] ──▢ [ test-meteor.js ] - * - * This file is the bundle that Rspack outputs for tests. It contains all of - * your test code in one optimized file. Next step is loading this bundle via - * `test-meteor.js`. - * - * ⚠️ Note: This file is autogenerated. It is not meant to be modified manually. - * These files also act as a cache: they can be safely removed and will be - * regenerated on the next build. They should be ignored in IDE suggestions - * and version control. - */ - -/* Code generated */ diff --git a/apps/meteor/rspack.config.js b/apps/meteor/rspack.config.js deleted file mode 100644 index 5e3064a5dbddb..0000000000000 --- a/apps/meteor/rspack.config.js +++ /dev/null @@ -1,15 +0,0 @@ -const { defineConfig } = require('@meteorjs/rspack'); - -/** - * Rspack configuration for Meteor projects. - * - * Provides typed flags on the `Meteor` object, such as: - * - `Meteor.isClient` / `Meteor.isServer` - * - `Meteor.isDevelopment` / `Meteor.isProduction` - * - …and other flags available - * - * Use these flags to adjust your build settings based on environment. - */ -module.exports = defineConfig((Meteor) => { - return {}; -}); From 96f13294cd9d19fa7910c5dd15ed84121e47f874 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 27 May 2026 13:25:17 -0300 Subject: [PATCH 04/16] refactor(useJoinRoom): always hit /v1/channels.join, TODO for unified join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the DDP fallback β€” non-channel rooms already failed via DDP too; the REST error path is equivalent. TODO marks the gap until a type-agnostic REST endpoint exists. --- .changeset/ddp-migrate-loadmissedmessages.md | 5 ----- apps/meteor/client/hooks/useJoinRoom.ts | 15 +++++---------- 2 files changed, 5 insertions(+), 15 deletions(-) delete mode 100644 .changeset/ddp-migrate-loadmissedmessages.md diff --git a/.changeset/ddp-migrate-loadmissedmessages.md b/.changeset/ddp-migrate-loadmissedmessages.md deleted file mode 100644 index bf185a048bda8..0000000000000 --- a/.changeset/ddp-migrate-loadmissedmessages.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@rocket.chat/meteor': patch ---- - -Migrate the `loadMissedMessages` DDP caller to `GET /v1/chat.syncMessages`. The client maps the serialized response through `mapMessageFromApi` so dates land as `Date` objects in the local Messages store. The DDP method keeps a deprecation log pointing at the REST route until the 9.0.0 sweep removes it. diff --git a/apps/meteor/client/hooks/useJoinRoom.ts b/apps/meteor/client/hooks/useJoinRoom.ts index 8ca8d449d8a8a..3685693222979 100644 --- a/apps/meteor/client/hooks/useJoinRoom.ts +++ b/apps/meteor/client/hooks/useJoinRoom.ts @@ -2,8 +2,6 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { sdk } from '../../app/utils/client/lib/SDKClient'; - type UseJoinRoomMutationFunctionProps = { rid: IRoom['_id']; reference: string; @@ -13,18 +11,15 @@ type UseJoinRoomMutationFunctionProps = { export const useJoinRoom = () => { const queryClient = useQueryClient(); const dispatchToastMessage = useToastMessageDispatch(); + // TODO(ddp-removal): /v1/channels.join only resolves public channels; non-`c` + // rooms will error here (same as DDP `joinRoom` would, just via REST). + // Replace with a unified `/v1/rooms.join` (or per-type endpoints) before + // the 9.0.0 sweep removes the DDP method. const joinChannel = useEndpoint('POST', '/v1/channels.join'); return useMutation({ mutationFn: async ({ rid, reference, type }: UseJoinRoomMutationFunctionProps) => { - if (type === 'c') { - await joinChannel({ roomId: rid }); - } else { - // /v1/channels.join only finds public channels; fall back to DDP for - // other room types (private groups, DMs, livechat). - await sdk.call('joinRoom', rid); - } - + await joinChannel({ roomId: rid }); return { reference, type }; }, onSuccess: (data) => { From 1e1129136559ef97024e5721dfd8135f8772f8d1 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 27 May 2026 13:29:16 -0300 Subject: [PATCH 05/16] chore: add deprecation log on joinRoom DDP method --- apps/meteor/app/lib/server/methods/joinRoom.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/meteor/app/lib/server/methods/joinRoom.ts b/apps/meteor/app/lib/server/methods/joinRoom.ts index 787f729bd578b..36c862726f8ee 100644 --- a/apps/meteor/app/lib/server/methods/joinRoom.ts +++ b/apps/meteor/app/lib/server/methods/joinRoom.ts @@ -5,6 +5,8 @@ import { Rooms } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -14,6 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async joinRoom(rid, code) { + methodDeprecationLogger.method('joinRoom', '9.0.0', '/v1/channels.join'); check(rid, String); const user = await Meteor.userAsync(); From 068b2528475e40cb6c326b09b15df6d2d84dda74 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 27 May 2026 14:55:21 -0300 Subject: [PATCH 06/16] chore: migrate 4 DDP callers via small REST schema extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit userSetUtcOffset β†’ /v1/users.setPreferences (utcOffset field added; server saves at user-document root via Users.setUtcOffset, not the settings.preferences subdoc). deleteFileMessage β†’ /v1/chat.delete (body now accepts fileId as an alternative to msgId+roomId; server resolves the message via Messages.getMessageByFileId). readThreads β†’ /v1/subscriptions.read (body now accepts tmid; server routes to readThread(tmid) instead of readMessages). spotlight β†’ /v1/spotlight (query now accepts usernames CSV, type JSON, rid; response type includes nickname/outside/uids/usernames/fname that the DDP version already returned). Each DDP method gets a deprecation log pointing at the REST route. --- apps/meteor/app/api/server/v1/chat.ts | 10 ++-- apps/meteor/app/api/server/v1/misc.ts | 17 +++++-- .../meteor/app/api/server/v1/subscriptions.ts | 14 +++++- .../NavBarSearch/hooks/useSearchItems.ts | 10 ++-- apps/meteor/client/startup/startup.ts | 4 +- .../RoomFiles/hooks/useDeleteFile.tsx | 6 +-- .../Threads/components/ThreadChat.tsx | 8 ++-- .../Threads/hooks/useThreadMessagesQuery.ts | 9 ++-- .../room/providers/ComposerPopupProvider.tsx | 17 +++++-- .../server/methods/deleteFileMessage.ts | 2 + apps/meteor/server/methods/readThreads.ts | 2 + .../server/methods/saveUserPreferences.ts | 8 ++++ .../meteor/server/methods/userSetUtcOffset.ts | 3 ++ apps/meteor/server/publications/spotlight.ts | 2 + packages/rest-typings/src/v1/chat.ts | 20 +++++--- packages/rest-typings/src/v1/misc.ts | 46 +++++++++++++++++-- .../src/v1/subscriptionsEndpoints.ts | 12 ++++- .../v1/users/UsersSetPreferenceParamsPOST.ts | 5 ++ 18 files changed, 155 insertions(+), 40 deletions(-) diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 383f4312c53b5..aef4216287123 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -546,13 +546,17 @@ const chatEndpoints = API.v1 }, }, async function action() { - const msg = await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); + const msg = + 'fileId' in this.bodyParams + ? await Messages.getMessageByFileId(this.bodyParams.fileId) + : await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); if (!msg) { - return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`); + const ref = 'fileId' in this.bodyParams ? `the file id of "${this.bodyParams.fileId}"` : `the id of "${this.bodyParams.msgId}"`; + return API.v1.failure(`No message found with ${ref}.`); } - if (this.bodyParams.roomId !== msg.rid) { + if ('roomId' in this.bodyParams && this.bodyParams.roomId !== msg.rid) { return API.v1.failure('The room id provided does not match where the message is from.'); } diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index d7b0be701a3f5..356337fca1ed3 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -6,6 +6,8 @@ import { ajv, isShieldSvgProps, isSpotlightProps, + parseSpotlightUsernames, + parseSpotlightType, isDirectoryProps, isFingerprintProps, isMeteorCall, @@ -339,7 +341,10 @@ API.v1.get( ); const spotlightResponseSchema = ajv.compile<{ - users: Pick[]; + users: (Pick & { + nickname?: string; + outside?: boolean; + })[]; rooms: Pick[]; }>({ type: 'object', @@ -392,9 +397,15 @@ API.v1.get( }, }, async function action() { - const { query } = this.queryParams; + const { query, usernames, type, rid } = this.queryParams; - const result = await spotlightMethod({ text: query, userId: this.userId }); + const result = await spotlightMethod({ + text: query, + userId: this.userId, + usernames: parseSpotlightUsernames(usernames), + type: parseSpotlightType(type), + rid, + }); return API.v1.success(result); }, diff --git a/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index 9b69aa5478d1c..f87ff7a93bc3d 100644 --- a/apps/meteor/app/api/server/v1/subscriptions.ts +++ b/apps/meteor/app/api/server/v1/subscriptions.ts @@ -1,5 +1,5 @@ import type { ISubscription } from '@rocket.chat/core-typings'; -import { Rooms, Subscriptions } from '@rocket.chat/models'; +import { Messages, Rooms, Subscriptions } from '@rocket.chat/models'; import { ajv, isSubscriptionsGetProps, @@ -14,6 +14,7 @@ import { Meteor } from 'meteor/meteor'; import { readMessages } from '../../../../server/lib/readMessages'; import { getSubscriptions } from '../../../../server/publications/subscription'; import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages'; +import { readThread } from '../../../threads/server/functions'; import { API } from '../api'; const subscriptionsGetResponseSchema = ajv.compile<{ @@ -139,7 +140,7 @@ API.v1.post( }, }, async function action() { - const { readThreads = false } = this.bodyParams; + const { readThreads = false, tmid } = this.bodyParams; const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId; const room = await Rooms.findOneById(roomId); @@ -147,6 +148,15 @@ API.v1.post( throw new Error('error-invalid-subscription'); } + if (tmid) { + const thread = await Messages.findOneById(tmid); + if (thread?.rid !== room._id) { + throw new Error('error-invalid-thread'); + } + await readThread({ user: this.user, room, tmid }); + return API.v1.success(); + } + await readMessages(room, this.userId, readThreads); return API.v1.success(); diff --git a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts index a44dceeb2f8f7..97b6b077d5b52 100644 --- a/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts +++ b/apps/meteor/client/navbar/NavBarSearch/hooks/useSearchItems.ts @@ -1,6 +1,6 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { useMethod, useUserSubscriptions } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useUserSubscriptions } from '@rocket.chat/ui-contexts'; import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { useMemo } from 'react'; @@ -47,7 +47,7 @@ export const useSearchItems = (filterText: string): UseQueryResult _id + name)], @@ -57,7 +57,11 @@ export const useSearchItems = (filterText: string): UseQueryResult index === arr.findIndex((user) => _id === user._id); diff --git a/apps/meteor/client/startup/startup.ts b/apps/meteor/client/startup/startup.ts index b9bb756cffb26..f68f6fd51df9e 100644 --- a/apps/meteor/client/startup/startup.ts +++ b/apps/meteor/client/startup/startup.ts @@ -34,7 +34,7 @@ if (!sdkTransportEnabled) { const utcOffset = -new Date().getTimezoneOffset() / 60; if (user.utcOffset !== utcOffset) { - sdk.call('userSetUtcOffset', utcOffset); + void sdk.rest.post('/v1/users.setPreferences', { data: { utcOffset } }); } emitStatusChange(user.status); @@ -79,7 +79,7 @@ if (!sdkTransportEnabled) { const utcOffset = -new Date().getTimezoneOffset() / 60; if (user.utcOffset !== utcOffset) { - sdk.call('userSetUtcOffset', utcOffset); + void sdk.rest.post('/v1/users.setPreferences', { data: { utcOffset } }); } emitStatusChange(user.status); diff --git a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx index bde721a6b269e..5cbaa7bf40d6c 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomFiles/hooks/useDeleteFile.tsx @@ -1,19 +1,19 @@ import type { IUpload } from '@rocket.chat/core-typings'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; import { GenericModal } from '@rocket.chat/ui-client'; -import { useSetModal, useToastMessageDispatch, useMethod } from '@rocket.chat/ui-contexts'; +import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts'; import { useTranslation } from 'react-i18next'; export const useDeleteFile = (reload: () => void) => { const { t } = useTranslation(); const setModal = useSetModal(); const dispatchToastMessage = useToastMessageDispatch(); - const deleteFile = useMethod('deleteFileMessage'); + const deleteMessage = useEndpoint('POST', '/v1/chat.delete'); const handleDelete = useStableCallback((_id: IUpload['_id']) => { const onConfirm = async () => { try { - await deleteFile(_id); + await deleteMessage({ fileId: _id }); dispatchToastMessage({ type: 'success', message: t('Deleted') }); reload(); } catch (error) { diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx index 079fbdde01da8..65dbaac381f0b 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -2,7 +2,7 @@ import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; import { Box, CheckBox, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import { clientCallbacks, ContextualbarContent } from '@rocket.chat/ui-client'; -import { useMethod, useTranslation, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useTranslation, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts'; import { useState, useEffect, useCallback, useId } from 'react'; import ThreadMessageList from './ThreadMessageList'; @@ -62,7 +62,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { }, [chat?.messageEditing]); const room = useRoom(); - const readThreads = useMethod('readThreads'); + const markThreadRead = useEndpoint('POST', '/v1/subscriptions.read'); useEffect(() => { clientCallbacks.add( 'streamNewMessage', @@ -71,7 +71,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { return; } - readThreads(mainMessage._id); + void markThreadRead({ rid: room._id, tmid: mainMessage._id }); }, clientCallbacks.priority.MEDIUM, `thread-${room._id}`, @@ -80,7 +80,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { return () => { clientCallbacks.remove('streamNewMessage', `thread-${room._id}`); }; - }, [mainMessage._id, readThreads, room._id]); + }, [mainMessage._id, markThreadRead, room._id]); const subscription = useRoomSubscription(); const sendToChannelID = useId(); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts index 4a6e1e7a2202b..f45d151d78cb0 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts @@ -1,5 +1,5 @@ import { isThreadMessage, type IMessage, type IRoom, type IThreadMainMessage, type IThreadMessage } from '@rocket.chat/core-typings'; -import { useEndpoint, useMethod, useStream } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useStream } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef } from 'react'; @@ -26,10 +26,7 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR const queryClient = useQueryClient(); const queryKey = roomsQueryKeys.threadMessages(roomId, tmid); const getThreadMessages = useEndpoint('GET', '/v1/chat.getThreadMessages'); - // REST has no per-thread read-marker endpoint yet; fall back to the - // `readThreads` DDP method so the side effect that DDP getThreadMessages - // used to do server-side keeps happening for callers. - const readThreads = useMethod('readThreads'); + const markThreadRead = useEndpoint('POST', '/v1/subscriptions.read'); const subscribeToRoomMessages = useStream('room-messages'); const subscribeToNotifyRoom = useStream('notify-room'); @@ -111,7 +108,7 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR const cachedMessages = queryClient.getQueryData(queryKey) || []; const { messages } = await getThreadMessages({ tmid }); - void Promise.resolve(readThreads(tmid)).catch(() => undefined); + void markThreadRead({ rid: roomId, tmid }).catch(() => undefined); const filtered = messages .map((m) => mapMessageFromApi(m)) .filter((msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true); diff --git a/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx b/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx index 6cf9eb4ee411f..7d9a6736fbcb6 100644 --- a/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx +++ b/apps/meteor/client/views/room/providers/ComposerPopupProvider.tsx @@ -3,7 +3,7 @@ import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; -import { useEndpoint, useMethod, useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useSetting, useUserId, useUserPreference } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; import type { ReactNode } from 'react'; @@ -70,7 +70,7 @@ const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) = // and we are not using the data itself, we should find a better way to do this useCannedResponsesQuery(room); - const userSpotlight = useMethod('spotlight'); + const userSpotlight = useEndpoint('GET', '/v1/spotlight'); const suggestionsCount = useSetting('Number_of_users_autocomplete_suggestions', 5); const cannedResponseEnabled = useSetting('Canned_Responses_Enable', true); const [recentEmojis] = useLocalStorage('emoji.recent', []); @@ -139,7 +139,12 @@ const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) = .slice(0, suggestionsCount ?? 5) .map((u) => u.username); - const { users = [] } = await userSpotlight(filter, usernames, { users: true, mentions: true }, rid); + const { users = [] } = await userSpotlight({ + query: filter, + usernames: usernames.join(','), + type: JSON.stringify({ users: true, mentions: true }), + rid, + }); return users.map(({ _id, username, nickname, name, status, avatarETag, outside }) => { return { @@ -178,7 +183,11 @@ const ComposerPopupProvider = ({ children, room }: ComposerPopupProviderProps) = return records; }, getItemsFromServer: async (filter: string) => { - const { rooms = [] } = await userSpotlight(filter, [], { rooms: true, mentions: true }, rid); + const { rooms = [] } = await userSpotlight({ + query: filter, + type: JSON.stringify({ rooms: true, mentions: true }), + rid, + }); return rooms as unknown as ComposerBoxPopupRoomProps[]; }, getValue: (item) => `${item.name || item.fname}`, diff --git a/apps/meteor/server/methods/deleteFileMessage.ts b/apps/meteor/server/methods/deleteFileMessage.ts index 39cfca1ae5dad..ca54a8e19490d 100644 --- a/apps/meteor/server/methods/deleteFileMessage.ts +++ b/apps/meteor/server/methods/deleteFileMessage.ts @@ -7,6 +7,7 @@ import type { DeleteResult } from 'mongodb'; import { FileUpload } from '../../app/file-upload/server'; import { deleteMessageValidatingPermission } from '../../app/lib/server/functions/deleteMessage'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -17,6 +18,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async deleteFileMessage(fileID) { + methodDeprecationLogger.method('deleteFileMessage', '9.0.0', '/v1/chat.delete'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { diff --git a/apps/meteor/server/methods/readThreads.ts b/apps/meteor/server/methods/readThreads.ts index c1a6fe3807350..6a97d7eba88f3 100644 --- a/apps/meteor/server/methods/readThreads.ts +++ b/apps/meteor/server/methods/readThreads.ts @@ -5,6 +5,7 @@ import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { canAccessRoomAsync } from '../../app/authorization/server'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { settings } from '../../app/settings/server'; import { readThread } from '../../app/threads/server/functions'; import { callbacks } from '../lib/callbacks'; @@ -18,6 +19,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async readThreads(tmid) { + methodDeprecationLogger.method('readThreads', '9.0.0', '/v1/subscriptions.read'); check(tmid, String); if (!Meteor.userId() || !settings.get('Threads_enabled')) { diff --git a/apps/meteor/server/methods/saveUserPreferences.ts b/apps/meteor/server/methods/saveUserPreferences.ts index d7582df642a34..2645e35dd47a7 100644 --- a/apps/meteor/server/methods/saveUserPreferences.ts +++ b/apps/meteor/server/methods/saveUserPreferences.ts @@ -53,6 +53,7 @@ type UserPreferences = { notifyCalendarEvents: boolean; enableMobileRinging: boolean; mentionsWithSymbol?: boolean; + utcOffset?: number; }; declare module '@rocket.chat/ddp-client' { @@ -128,6 +129,7 @@ export const saveUserPreferences = async (settings: Partial, us notifyCalendarEvents: Match.Optional(Boolean), enableMobileRinging: Match.Optional(Boolean), mentionsWithSymbol: Match.Optional(Boolean), + utcOffset: Match.Optional(Number), }; check(settings, Match.ObjectIncluding(keys)); @@ -151,6 +153,12 @@ export const saveUserPreferences = async (settings: Partial, us await Users.setLanguage(user._id, settings.language); } + // utcOffset lives at the user-document root (not under settings.preferences) + if (settings.utcOffset != null) { + await Users.setUtcOffset(user._id, settings.utcOffset); + delete settings.utcOffset; + } + // Keep compatibility with old values if (settings.emailNotificationMode === 'all') { settings.emailNotificationMode = 'mentions'; diff --git a/apps/meteor/server/methods/userSetUtcOffset.ts b/apps/meteor/server/methods/userSetUtcOffset.ts index 819b9c1b92f1d..61357e794156b 100644 --- a/apps/meteor/server/methods/userSetUtcOffset.ts +++ b/apps/meteor/server/methods/userSetUtcOffset.ts @@ -4,6 +4,8 @@ import { check } from 'meteor/check'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -13,6 +15,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async userSetUtcOffset(utcOffset) { + methodDeprecationLogger.method('userSetUtcOffset', '9.0.0', '/v1/users.setPreferences'); check(utcOffset, Number); if (!this.userId) { diff --git a/apps/meteor/server/publications/spotlight.ts b/apps/meteor/server/publications/spotlight.ts index d32436e237744..b6c785775123a 100644 --- a/apps/meteor/server/publications/spotlight.ts +++ b/apps/meteor/server/publications/spotlight.ts @@ -2,6 +2,7 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { DDPRateLimiter } from 'meteor/ddp-rate-limiter'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; import { Spotlight } from '../lib/spotlight'; type SpotlightType = { @@ -68,6 +69,7 @@ export const spotlightMethod = async ({ Meteor.methods({ async spotlight(text, usernames = [], type = { users: true, rooms: true, mentions: false, includeFederatedRooms: false }, rid) { + methodDeprecationLogger.method('spotlight', '9.0.0', '/v1/spotlight'); return spotlightMethod({ text, usernames, type, rid, userId: this.userId }); }, }); diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 8e8d52479e504..67f684eb3487b 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -218,11 +218,16 @@ const ChatSyncThreadsListSchema = { export const isChatSyncThreadsListProps = ajv.compile(ChatSyncThreadsListSchema); -type ChatDelete = { - msgId: IMessage['_id']; - roomId: IRoom['_id']; - asUser?: boolean; -}; +type ChatDelete = + | { + msgId: IMessage['_id']; + roomId: IRoom['_id']; + asUser?: boolean; + } + | { + fileId: string; + asUser?: boolean; + }; const ChatDeleteSchema = { type: 'object', @@ -233,12 +238,15 @@ const ChatDeleteSchema = { roomId: { type: 'string', }, + fileId: { + type: 'string', + }, asUser: { type: 'boolean', nullable: true, }, }, - required: ['msgId', 'roomId'], + anyOf: [{ required: ['msgId', 'roomId'] }, { required: ['fileId'] }], additionalProperties: false, }; diff --git a/packages/rest-typings/src/v1/misc.ts b/packages/rest-typings/src/v1/misc.ts index 5df161c0e1c33..42ebb2679f52a 100644 --- a/packages/rest-typings/src/v1/misc.ts +++ b/packages/rest-typings/src/v1/misc.ts @@ -38,7 +38,19 @@ const ShieldSvgSchema = { export const isShieldSvgProps = ajv.compile(ShieldSvgSchema); -type Spotlight = { query: string }; +type SpotlightType = { + users?: boolean; + mentions?: boolean; + rooms?: boolean; + includeFederatedRooms?: boolean; +}; + +type Spotlight = { + query: string; + usernames?: string; + type?: string; + rid?: string; +}; const SpotlightSchema = { type: 'object', @@ -46,6 +58,18 @@ const SpotlightSchema = { query: { type: 'string', }, + usernames: { + type: 'string', + nullable: true, + }, + type: { + type: 'string', + nullable: true, + }, + rid: { + type: 'string', + nullable: true, + }, }, required: ['query'], additionalProperties: false, @@ -53,6 +77,21 @@ const SpotlightSchema = { export const isSpotlightProps = ajvQuery.compile(SpotlightSchema); +const parseSpotlightUsernames = (usernames?: string): string[] | undefined => + usernames ? usernames.split(',').filter(Boolean) : undefined; +const parseSpotlightType = (raw?: string): SpotlightType | undefined => { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as SpotlightType; + return parsed && typeof parsed === 'object' ? parsed : undefined; + } catch { + return undefined; + } +}; + +export { parseSpotlightUsernames, parseSpotlightType }; +export type { SpotlightType }; + type Directory = PaginatedRequest<{ text: string; type: string; @@ -185,8 +224,9 @@ export type MiscEndpoints = { '/v1/spotlight': { GET: (params: Spotlight) => { - users: (Pick, 'name' | 'status' | '_id' | 'username'> & Partial>)[]; - rooms: Pick, 't' | 'name' | 'lastMessage' | '_id'>[]; + users: (Pick, 'name' | 'status' | '_id' | 'username'> & + Partial> & { nickname?: string; outside?: boolean })[]; + rooms: (Pick, 't' | 'name' | 'lastMessage' | '_id'> & { uids?: string[]; usernames?: string[]; fname?: string })[]; }; }; diff --git a/packages/rest-typings/src/v1/subscriptionsEndpoints.ts b/packages/rest-typings/src/v1/subscriptionsEndpoints.ts index d188f4d594f69..46026000beb2e 100644 --- a/packages/rest-typings/src/v1/subscriptionsEndpoints.ts +++ b/packages/rest-typings/src/v1/subscriptionsEndpoints.ts @@ -6,7 +6,9 @@ type SubscriptionsGet = { updatedSince?: string }; type SubscriptionsGetOne = { roomId: IRoom['_id'] }; -type SubscriptionsRead = { rid: IRoom['_id']; readThreads?: boolean } | { roomId: IRoom['_id']; readThreads?: boolean }; +type SubscriptionsRead = + | { rid: IRoom['_id']; readThreads?: boolean; tmid?: IMessage['_id'] } + | { roomId: IRoom['_id']; readThreads?: boolean; tmid?: IMessage['_id'] }; type SubscriptionsUnread = { roomId: IRoom['_id'] } | { firstUnreadMessage: Pick }; @@ -49,6 +51,10 @@ const SubscriptionsReadSchema = { type: 'boolean', nullable: true, }, + tmid: { + type: 'string', + nullable: true, + }, }, required: ['rid'], additionalProperties: false, @@ -63,6 +69,10 @@ const SubscriptionsReadSchema = { type: 'boolean', nullable: true, }, + tmid: { + type: 'string', + nullable: true, + }, }, required: ['roomId'], additionalProperties: false, diff --git a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts index 6101dea65cbbc..18b6986f1aaec 100644 --- a/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts +++ b/packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts @@ -53,6 +53,7 @@ export type UsersSetPreferencesParamsPOST = { enableMobileRinging?: boolean; mentionsWithSymbol?: boolean; desktopNotificationVoiceCalls?: boolean; + utcOffset?: number; }; }; @@ -268,6 +269,10 @@ const UsersSetPreferencesParamsPostSchema = { type: 'boolean', nullable: true, }, + utcOffset: { + type: 'number', + nullable: true, + }, }, required: [], additionalProperties: false, From b6e8b255df54d813d5f47e770df27f492174598b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 27 May 2026 16:18:55 -0300 Subject: [PATCH 07/16] fix(spotlight): mark status optional on response schema Server returns outside/external users without a status field (and DDP callers happily handled the absence). Marking it required broke the response validation: GET /v1/spotlight 'must have required property status'. --- apps/meteor/app/api/server/v1/misc.ts | 4 ++-- packages/rest-typings/src/v1/misc.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index 356337fca1ed3..1ec785a1257af 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -341,7 +341,7 @@ API.v1.get( ); const spotlightResponseSchema = ajv.compile<{ - users: (Pick & { + users: (Pick & Partial> & { nickname?: string; outside?: boolean; })[]; @@ -361,7 +361,7 @@ const spotlightResponseSchema = ajv.compile<{ statusText: { type: 'string' }, avatarETag: { type: 'string' }, }, - required: ['_id', 'name', 'username', 'status'], + required: ['_id', 'name', 'username'], additionalProperties: true, }, }, diff --git a/packages/rest-typings/src/v1/misc.ts b/packages/rest-typings/src/v1/misc.ts index 42ebb2679f52a..7224e163fbaea 100644 --- a/packages/rest-typings/src/v1/misc.ts +++ b/packages/rest-typings/src/v1/misc.ts @@ -224,8 +224,8 @@ export type MiscEndpoints = { '/v1/spotlight': { GET: (params: Spotlight) => { - users: (Pick, 'name' | 'status' | '_id' | 'username'> & - Partial> & { nickname?: string; outside?: boolean })[]; + users: (Pick, 'name' | '_id' | 'username'> & + Partial> & { nickname?: string; outside?: boolean })[]; rooms: (Pick, 't' | 'name' | 'lastMessage' | '_id'> & { uids?: string[]; usernames?: string[]; fname?: string })[]; }; }; From 22e528bbe5f77d82a8c88bf56ff5bb951e11d02f Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 27 May 2026 17:30:54 -0300 Subject: [PATCH 08/16] style: prettier fix on spotlight response schema type --- apps/meteor/app/api/server/v1/misc.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index 1ec785a1257af..c4ad7f42d8e3e 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -341,10 +341,11 @@ API.v1.get( ); const spotlightResponseSchema = ajv.compile<{ - users: (Pick & Partial> & { - nickname?: string; - outside?: boolean; - })[]; + users: (Pick & + Partial> & { + nickname?: string; + outside?: boolean; + })[]; rooms: Pick[]; }>({ type: 'object', From 362e0ae0efe5a6f1f201c61731861eac7e8e0b9b Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Wed, 27 May 2026 23:40:31 -0300 Subject: [PATCH 09/16] fix: migrate data.ts joinRoom to /v1/channels.join The hooks/useJoinRoom was already migrated. data.ts (the chat-data API consumed by message flows) still went through DDP, which throws under TEST_MODE='true' because of the deprecation log and broke beforeEach hooks in anonymous-user / embedded-layout specs. --- apps/meteor/client/lib/chats/data.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/meteor/client/lib/chats/data.ts b/apps/meteor/client/lib/chats/data.ts index fe22df8d10646..75f4d457b36af 100644 --- a/apps/meteor/client/lib/chats/data.ts +++ b/apps/meteor/client/lib/chats/data.ts @@ -274,7 +274,10 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage const isSubscribedToRoom = async (): Promise => !!Subscriptions.state.find((record) => record.rid === rid); const joinRoom = async (): Promise => { - await sdk.call('joinRoom', rid); + // TODO(ddp-removal): only public channels resolve through this endpoint; + // private groups, DMs and livechat used to error via DDP too β€” REST keeps + // that behavior. Replace with a unified `/v1/rooms.join` when available. + await sdk.rest.post('/v1/channels.join', { roomId: rid }); }; const findDiscussionByID = async (drid: IRoom['_id']): Promise => From 4d71f24a3eb602c5790bff5a7300548bff8eb628 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 12:12:15 -0300 Subject: [PATCH 10/16] fix(spotlight): allow anonymous calls DDP spotlight ran for anonymous DDP sessions (no this.userId), which was how the anonymous-user e2e specs reached 'general' via the navbar search. The REST migration kept authRequired: true and broke those specs (along with embedded-layout) because navbar.openChat hung on the empty listbox. Set authRequired: false and pass the optional this.userId through. --- apps/meteor/app/api/server/v1/misc.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/meteor/app/api/server/v1/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index c4ad7f42d8e3e..44ebb96478b23 100644 --- a/apps/meteor/app/api/server/v1/misc.ts +++ b/apps/meteor/app/api/server/v1/misc.ts @@ -389,7 +389,10 @@ const spotlightResponseSchema = ajv.compile<{ API.v1.get( 'spotlight', { - authRequired: true, + // DDP `spotlight` accepts anonymous calls (Accounts_AllowAnonymousRead). + // Keep parity so anonymous-user / embedded-layout flows can still + // resolve a public channel through the navbar search. + authRequired: false, query: isSpotlightProps, response: { 200: spotlightResponseSchema, From daa64d60e275216da1ab20abb0adc8e34b5c921c Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 14:43:47 -0300 Subject: [PATCH 11/16] chore: migrate listCustomSounds caller to /v1/custom-sounds.list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The REST endpoint already exists (paginated). Caller in CustomSoundProvider switches from `sdk.call('listCustomSounds')` to `useEndpoint('GET', '/v1/custom-sounds.list')`, drops `_updatedAt` from the spread to satisfy the Omit<…, '_updatedAt'> return type, and the helper now takes a narrower shape so REST + the local defaultSounds list type-check the same way. Server method gets a deprecation log pointing at the REST route. --- .../custom-sounds/server/methods/listCustomSounds.ts | 3 +++ .../CustomSoundProvider/CustomSoundProvider.tsx | 10 +++++----- .../providers/CustomSoundProvider/lib/helpers.ts | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/custom-sounds/server/methods/listCustomSounds.ts b/apps/meteor/app/custom-sounds/server/methods/listCustomSounds.ts index eda1325d77335..e627b96368f47 100644 --- a/apps/meteor/app/custom-sounds/server/methods/listCustomSounds.ts +++ b/apps/meteor/app/custom-sounds/server/methods/listCustomSounds.ts @@ -3,6 +3,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; import { CustomSounds } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; + declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -12,6 +14,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async listCustomSounds() { + methodDeprecationLogger.method('listCustomSounds', '9.0.0', '/v1/custom-sounds.list'); return CustomSounds.find({}).toArray(); }, }); diff --git a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx index 9cda0dc4cb08f..b6a57304fb5ce 100644 --- a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx +++ b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx @@ -1,11 +1,10 @@ import type { ICustomSound } from '@rocket.chat/core-typings'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; -import { CustomSoundContext, useStream, useUserPreference } from '@rocket.chat/ui-contexts'; +import { CustomSoundContext, useEndpoint, useStream, useUserPreference } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useMemo, useRef, type ReactNode } from 'react'; import { defaultSounds, getCustomSoundURL, formatVolume } from './lib'; -import { sdk } from '../../../app/utils/client/lib/SDKClient'; import { useUserSoundPreferences } from '../../hooks/useUserSoundPreferences'; type CustomSoundProviderProps = { @@ -17,6 +16,7 @@ const CustomSoundProvider = ({ children }: CustomSoundProviderProps) => { const queryClient = useQueryClient(); const streamAll = useStream('notify-all'); + const getCustomSounds = useEndpoint('GET', '/v1/custom-sounds.list'); const newRoomNotification = useUserPreference('newRoomNotification') || 'door'; const newMessageNotification = useUserPreference('newMessageNotification') || 'chime'; @@ -24,11 +24,11 @@ const CustomSoundProvider = ({ children }: CustomSoundProviderProps) => { const { data: list } = useQuery({ queryFn: async (): Promise[]> => { - const customSoundsList = await sdk.call('listCustomSounds'); - if (!customSoundsList.length) { + const { sounds } = await getCustomSounds({}); + if (!sounds.length) { return defaultSounds; } - return [...customSoundsList.map((sound) => ({ ...sound, src: getCustomSoundURL(sound) })), ...defaultSounds]; + return [...sounds.map(({ _updatedAt: _, ...sound }) => ({ ...sound, src: getCustomSoundURL(sound) })), ...defaultSounds]; }, queryKey: ['listCustomSounds'], initialData: defaultSounds, diff --git a/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts b/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts index 5383ed6e9fe95..90446325eb005 100644 --- a/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts +++ b/apps/meteor/client/providers/CustomSoundProvider/lib/helpers.ts @@ -4,7 +4,7 @@ import { getURL } from '../../../../app/utils/client'; export const getAssetUrl = (asset: string, params?: Record) => getURL(asset, params, undefined, true); -export const getCustomSoundURL = (sound: ICustomSound) => { +export const getCustomSoundURL = (sound: Pick & Pick, 'random'>) => { return getAssetUrl(`/custom-sounds/${sound._id}.${sound.extension}`, { _dc: sound.random || 0 }); }; From 5c78031917659dd5b9521a7787aec539765c2454 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 28 May 2026 14:52:04 -0300 Subject: [PATCH 12/16] chore(changeset): add changesets for batch2 REST endpoint extensions Five entries: one per touched REST endpoint (chat.delete, users.setPreferences, subscriptions.read, spotlight) plus a single summary for the client caller swaps that don't change any REST shape. --- .changeset/ddp-migrate-batch2-callers.md | 13 +++++++++++++ .changeset/rest-chat-delete-by-fileid.md | 6 ++++++ .changeset/rest-spotlight-params-and-anonymous.md | 11 +++++++++++ .changeset/rest-subscriptions-read-tmid.md | 6 ++++++ .changeset/rest-users-setpreferences-utcoffset.md | 6 ++++++ 5 files changed, 42 insertions(+) create mode 100644 .changeset/ddp-migrate-batch2-callers.md create mode 100644 .changeset/rest-chat-delete-by-fileid.md create mode 100644 .changeset/rest-spotlight-params-and-anonymous.md create mode 100644 .changeset/rest-subscriptions-read-tmid.md create mode 100644 .changeset/rest-users-setpreferences-utcoffset.md diff --git a/.changeset/ddp-migrate-batch2-callers.md b/.changeset/ddp-migrate-batch2-callers.md new file mode 100644 index 0000000000000..5891f468028b9 --- /dev/null +++ b/.changeset/ddp-migrate-batch2-callers.md @@ -0,0 +1,13 @@ +--- +'@rocket.chat/meteor': patch +--- + +Migrate seven client DDP callers to their REST equivalents (the DDP methods stay registered on the server for external SDK/mobile clients, with a deprecation log pointing at the REST route until 9.0.0 removes them): + +- `loadMissedMessages` β†’ `GET /v1/chat.syncMessages` +- `joinRoom` β†’ `POST /v1/channels.join` (channel-only; non-`c` rooms now error via REST the same way they used to via DDP) +- `userSetUtcOffset` β†’ `POST /v1/users.setPreferences` (new `utcOffset` field) +- `deleteFileMessage` β†’ `POST /v1/chat.delete` (new `fileId` body shape) +- `readThreads` β†’ `POST /v1/subscriptions.read` (new `tmid` field) +- `spotlight` β†’ `GET /v1/spotlight` (new `usernames` / `type` / `rid` query params) +- `listCustomSounds` β†’ `GET /v1/custom-sounds.list` diff --git a/.changeset/rest-chat-delete-by-fileid.md b/.changeset/rest-chat-delete-by-fileid.md new file mode 100644 index 0000000000000..56a660a415400 --- /dev/null +++ b/.changeset/rest-chat-delete-by-fileid.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +`POST /v1/chat.delete` now accepts `{ fileId, asUser? }` as an alternative to `{ msgId, roomId, asUser? }`. When `fileId` is provided the server resolves the owning message via `Messages.getMessageByFileId` before running the existing permission and deletion flow. diff --git a/.changeset/rest-spotlight-params-and-anonymous.md b/.changeset/rest-spotlight-params-and-anonymous.md new file mode 100644 index 0000000000000..032538dd9b963 --- /dev/null +++ b/.changeset/rest-spotlight-params-and-anonymous.md @@ -0,0 +1,11 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +`GET /v1/spotlight` now mirrors the DDP `spotlight` method: + +- accepts optional `usernames` (comma-separated string), `type` (JSON-encoded `{ users?, mentions?, rooms?, includeFederatedRooms? }`) and `rid` query params; +- response items expose `nickname` / `outside` (users) and `uids` / `usernames` / `fname` (rooms); +- `status` on each user is now optional β€” outside/federated users were already being returned without one and the previous required-field schema rejected them as `Response validation failed`; +- the endpoint is no longer auth-gated, allowing anonymous-read flows (e.g. `Accounts_AllowAnonymousRead`) to keep finding public channels through the navbar search. diff --git a/.changeset/rest-subscriptions-read-tmid.md b/.changeset/rest-subscriptions-read-tmid.md new file mode 100644 index 0000000000000..4439198dae548 --- /dev/null +++ b/.changeset/rest-subscriptions-read-tmid.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +`POST /v1/subscriptions.read` now accepts an optional `tmid` field. When provided the server marks the specific thread as read via `readThread({ user, room, tmid })` instead of marking the whole room. diff --git a/.changeset/rest-users-setpreferences-utcoffset.md b/.changeset/rest-users-setpreferences-utcoffset.md new file mode 100644 index 0000000000000..9681c8b57f0cc --- /dev/null +++ b/.changeset/rest-users-setpreferences-utcoffset.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +`POST /v1/users.setPreferences` now accepts an optional `data.utcOffset` (number) field. The value is stored at the user-document root via `Users.setUtcOffset` (not under `settings.preferences`), matching what the legacy `userSetUtcOffset` DDP method did. From 46a920e6c9d9fa013a6ff9a307261cef98d3f9d9 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 11 Jun 2026 10:18:28 -0300 Subject: [PATCH 13/16] fix: address review on batch2 DDP migration - CustomSoundProvider: page through /v1/custom-sounds.list until total reached so all sounds load (legacy listCustomSounds returned all at once) - threads: extract readThreadMethod replicating legacy readThreads behavior (Threads_enabled, room access, beforeReadMessages callback); use it in both the DDP method and the subscriptions.read tmid branch - chat.delete: support deleting an uploaded file with no associated message via the Uploads store; make _id/ts/message optional in the response - useLoadMissedMessages: only upsert genuinely-new or already-loaded synced messages and remove deleted ones, avoiding stale-history pollution from the _updatedAt-based chat.syncMessages - add API tests for subscriptions.read (tmid), chat.delete (fileId), spotlight (type/usernames/anonymous) and users.setPreferences (utcOffset) --- apps/meteor/app/api/server/v1/chat.ts | 38 ++++++++-- .../meteor/app/api/server/v1/subscriptions.ts | 4 +- apps/meteor/app/threads/server/functions.ts | 34 ++++++++- .../CustomSoundProvider.tsx | 15 +++- .../views/root/hooks/useLoadMissedMessages.ts | 21 +++++- apps/meteor/server/methods/readThreads.ts | 34 ++------- apps/meteor/tests/end-to-end/api/chat.ts | 73 +++++++++++++++++++ .../tests/end-to-end/api/miscellaneous.ts | 49 +++++++++++++ .../tests/end-to-end/api/subscriptions.ts | 53 ++++++++++++++ apps/meteor/tests/end-to-end/api/users.ts | 34 +++++++++ packages/rest-typings/src/v1/chat.ts | 6 +- 11 files changed, 317 insertions(+), 44 deletions(-) diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index aef4216287123..8de55ffe4f529 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -1,7 +1,7 @@ -import { Message } from '@rocket.chat/core-services'; +import { Message, Upload } from '@rocket.chat/core-services'; import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; import { MessageTypes } from '@rocket.chat/message-types'; -import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; +import { Messages, Users, Rooms, Subscriptions, Uploads } from '@rocket.chat/models'; import { ajv, isChatReportMessageProps, @@ -37,6 +37,7 @@ import { getMessageHistory } from '../../../../server/publications/messages'; import { roomAccessAttributes } from '../../../authorization/server'; import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { FileUpload } from '../../../file-upload/server'; import { deleteMessageValidatingPermission } from '../../../lib/server/functions/deleteMessage'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { getSingleMessage } from '../../../lib/server/methods/getSingleMessage'; @@ -521,7 +522,7 @@ const chatEndpoints = API.v1 authRequired: true, body: isChatDeleteProps, response: { - 200: ajv.compile<{ _id: string; ts: string; message: Pick }>({ + 200: ajv.compile<{ _id?: string; ts?: string; message?: Pick }>({ type: 'object', properties: { _id: { type: 'string' }, @@ -538,7 +539,7 @@ const chatEndpoints = API.v1 }, success: { type: 'boolean', enum: [true] }, }, - required: ['_id', 'ts', 'message', 'success'], + required: ['success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, @@ -552,8 +553,31 @@ const chatEndpoints = API.v1 : await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); if (!msg) { - const ref = 'fileId' in this.bodyParams ? `the file id of "${this.bodyParams.fileId}"` : `the id of "${this.bodyParams.msgId}"`; - return API.v1.failure(`No message found with ${ref}.`); + // The legacy `deleteFileMessage` method allowed removing an uploaded file + // that had no associated message by deleting it straight from the store, + // validating the upload-delete permission first. + if ('fileId' in this.bodyParams) { + const user = await Users.findOneById(this.userId, { projection: { username: 1 } }); + if (!user) { + return API.v1.failure('User not found'); + } + + const file = await Uploads.findOneById(this.bodyParams.fileId, { + projection: { userId: 1, rid: 1, expiresAt: 1, uploadedAt: 1 }, + }); + if (!file) { + return API.v1.failure(`No file found with the file id of "${this.bodyParams.fileId}".`); + } + + if (!(await Upload.canDeleteFile(user, file, null))) { + return API.v1.failure('Unauthorized. You are not allowed to delete this file.'); + } + + await FileUpload.getStore('Uploads').deleteById(this.bodyParams.fileId); + return API.v1.success(); + } + + return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`); } if ('roomId' in this.bodyParams && this.bodyParams.roomId !== msg.rid) { @@ -580,7 +604,7 @@ const chatEndpoints = API.v1 return API.v1.success({ _id: msg._id, ts: Date.now().toString(), - message: msg, + message: { _id: msg._id, rid: msg.rid, u: msg.u }, }); }, ) diff --git a/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index f87ff7a93bc3d..1d51ee86746c3 100644 --- a/apps/meteor/app/api/server/v1/subscriptions.ts +++ b/apps/meteor/app/api/server/v1/subscriptions.ts @@ -14,7 +14,7 @@ import { Meteor } from 'meteor/meteor'; import { readMessages } from '../../../../server/lib/readMessages'; import { getSubscriptions } from '../../../../server/publications/subscription'; import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages'; -import { readThread } from '../../../threads/server/functions'; +import { readThreadMethod } from '../../../threads/server/functions'; import { API } from '../api'; const subscriptionsGetResponseSchema = ajv.compile<{ @@ -153,7 +153,7 @@ API.v1.post( if (thread?.rid !== room._id) { throw new Error('error-invalid-thread'); } - await readThread({ user: this.user, room, tmid }); + await readThreadMethod({ user: this.user, tmid }); return API.v1.success(); } diff --git a/apps/meteor/app/threads/server/functions.ts b/apps/meteor/app/threads/server/functions.ts index 4c5db1ddf70b5..f7dd0f117a28c 100644 --- a/apps/meteor/app/threads/server/functions.ts +++ b/apps/meteor/app/threads/server/functions.ts @@ -1,13 +1,16 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; -import { Messages, Subscriptions, NotificationQueue } from '@rocket.chat/models'; +import { Messages, Rooms, Subscriptions, NotificationQueue } from '@rocket.chat/models'; +import { Meteor } from 'meteor/meteor'; import { callbacks } from '../../../server/lib/callbacks'; +import { canAccessRoomAsync } from '../../authorization/server'; import { notifyOnSubscriptionChangedByRoomIdAndUserIds, notifyOnSubscriptionChangedByRoomIdAndUserId, } from '../../lib/server/lib/notifyListener'; import { getMentions, getUserIdsFromHighlights } from '../../lib/server/lib/notifyUsersOnMessage'; +import { settings } from '../../settings/server'; export async function reply({ tmid }: { tmid?: string }, message: IMessage, parentMessage: IMessage, followers: string[]) { if (!tmid || isEditedMessage(message)) { @@ -98,3 +101,32 @@ export const readThread = async ({ user, room, tmid }: { user: IUser; room: IRoo callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid }); }; + +/** + * Marks a thread as read for a user, replicating the full behavior of the + * legacy `readThreads` DDP method: it validates that threads are enabled, that + * the thread and its room exist, that the user can access the room, and runs + * the `beforeReadMessages` callback before clearing the thread unread state. + */ +export const readThreadMethod = async ({ user, tmid }: { user: IUser; tmid: IMessage['_id'] }) => { + if (!settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { method: 'readThreads' }); + } + + const thread = await Messages.findOneById(tmid); + if (!thread) { + return; + } + + const room = await Rooms.findOneById(thread.rid); + if (!room) { + throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'readThreads' }); + } + + if (!(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'readThreads' }); + } + + await callbacks.run('beforeReadMessages', thread.rid, user._id); + await readThread({ user, room, tmid }); +}; diff --git a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx index b6a57304fb5ce..1b90df8444fc2 100644 --- a/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx +++ b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx @@ -24,7 +24,20 @@ const CustomSoundProvider = ({ children }: CustomSoundProviderProps) => { const { data: list } = useQuery({ queryFn: async (): Promise[]> => { - const { sounds } = await getCustomSounds({}); + // `/v1/custom-sounds.list` is paginated, so we page through all results to + // load every custom sound into the provider (the legacy `listCustomSounds` + // method returned them all at once). + const sounds: Awaited>['sounds'] = []; + let total = Infinity; + while (sounds.length < total) { + const page = await getCustomSounds({ count: 100, offset: sounds.length }); + total = page.total; + sounds.push(...page.sounds); + if (!page.sounds.length) { + break; + } + } + if (!sounds.length) { return defaultSounds; } diff --git a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts index 80c62719b749d..631a7c107e0a9 100644 --- a/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts +++ b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts @@ -26,10 +26,29 @@ const loadMissedMessages = async (rid: IRoom['_id']): Promise => { roomId: rid, lastUpdate: lastMessage.ts.toISOString(), }); + if (result?.updated?.length) { const subscription = Subscriptions.state.find((record) => record.rid === rid); - await Promise.all(result.updated.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); } diff --git a/apps/meteor/server/methods/readThreads.ts b/apps/meteor/server/methods/readThreads.ts index 6a97d7eba88f3..74c3d03293f1b 100644 --- a/apps/meteor/server/methods/readThreads.ts +++ b/apps/meteor/server/methods/readThreads.ts @@ -1,14 +1,10 @@ import type { IMessage, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { Messages, Rooms } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { canAccessRoomAsync } from '../../app/authorization/server'; import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; -import { settings } from '../../app/settings/server'; -import { readThread } from '../../app/threads/server/functions'; -import { callbacks } from '../lib/callbacks'; +import { readThreadMethod } from '../../app/threads/server/functions'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -22,31 +18,11 @@ Meteor.methods({ methodDeprecationLogger.method('readThreads', '9.0.0', '/v1/subscriptions.read'); check(tmid, String); - if (!Meteor.userId() || !settings.get('Threads_enabled')) { - throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { - method: 'getThreadMessages', - }); + const user = (await Meteor.userAsync()) as IUser | null; + if (!user) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'readThreads' }); } - const thread = await Messages.findOneById(tmid); - if (!thread) { - return; - } - - const user = (await Meteor.userAsync()) ?? undefined; - - const room = await Rooms.findOneById(thread.rid); - if (!room) { - throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'getThreadMessages' }); - } - - if (!(await canAccessRoomAsync(room, user))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getThreadMessages' }); - } - - await callbacks.run('beforeReadMessages', thread.rid, user?._id); - if (user?._id) { - await readThread({ user: user as IUser, room, tmid }); - } + await readThreadMethod({ user, tmid }); }, }); diff --git a/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts index 8cad3dee79251..e7dbb5cba2b79 100644 --- a/apps/meteor/tests/end-to-end/api/chat.ts +++ b/apps/meteor/tests/end-to-end/api/chat.ts @@ -9,6 +9,7 @@ import { retry } from './helpers/retry'; import { sleep } from '../../../lib/utils/sleep'; import { getCredentials, api, request, credentials, apiUrl } from '../../data/api-data'; import { followMessage, sendSimpleMessage, deleteMessage } from '../../data/chat.helper'; +import { imgURL } from '../../data/interactions'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { addUserToRoom, createRoom, deleteRoom, getSubscriptionByRoomId } from '../../data/rooms.helper'; import { password } from '../../data/user'; @@ -2338,6 +2339,78 @@ describe('[Chat]', () => { .end(done); }); + describe('when deleting by fileId', () => { + const uploadFile = async (): Promise => { + const { body } = await request + .post(api(`rooms.media/${testChannel._id}`)) + .set(credentials) + .attach('file', imgURL) + .expect(200); + expect(body).to.have.property('success', true); + return body.file._id; + }; + + it('should delete the message associated with the provided fileId', async () => { + const fileId = await uploadFile(); + + let fileMsgId: string | undefined; + await request + .post(api(`rooms.mediaConfirm/${testChannel._id}/${fileId}`)) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + fileMsgId = res.body.message._id; + }); + + await request + .post(api('chat.delete')) + .set(credentials) + .send({ fileId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('chat.getMessage')) + .set(credentials) + .query({ msgId: fileMsgId }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('should remove an uploaded file that has no associated message', async () => { + const fileId = await uploadFile(); + + await request + .post(api('chat.delete')) + .set(credentials) + .send({ fileId }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('should fail when neither msgId/roomId nor fileId is provided', async () => { + await request + .post(api('chat.delete')) + .set(credentials) + .send({}) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + }); + describe('when deleting a thread message', () => { let otherUser: TestUser; let otherUserCredentials: Credentials; diff --git a/apps/meteor/tests/end-to-end/api/miscellaneous.ts b/apps/meteor/tests/end-to-end/api/miscellaneous.ts index 1196c95e24a49..3e4ad18ab1de8 100644 --- a/apps/meteor/tests/end-to-end/api/miscellaneous.ts +++ b/apps/meteor/tests/end-to-end/api/miscellaneous.ts @@ -549,6 +549,55 @@ describe('miscellaneous', () => { }) .end(done); }); + it('should not return users when the type param disables user search', (done) => { + void request + .get(api('spotlight')) + .query({ + query: `${adminUsername}`, + type: JSON.stringify({ users: false, rooms: true }), + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array').that.is.empty; + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + }) + .end(done); + }); + it('should exclude usernames passed in the usernames param from the results', (done) => { + void request + .get(api('spotlight')) + .query({ + query: `@${adminUsername}`, + usernames: adminUsername, + }) + .set(credentials) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('users').and.to.be.an('array'); + expect(res.body.users.map((u: { username: string }) => u.username)).to.not.include(adminUsername); + }) + .end(done); + }); + it('should allow anonymous (unauthenticated) requests', (done) => { + void request + .get(api('spotlight')) + .query({ + query: `#${testChannel.name}`, + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rooms').and.to.be.an('array'); + expect(res.body).to.have.property('users').and.to.be.an('array'); + }) + .end(done); + }); }); describe('[/instances.get]', () => { diff --git a/apps/meteor/tests/end-to-end/api/subscriptions.ts b/apps/meteor/tests/end-to-end/api/subscriptions.ts index 7379e59606aab..517f96c2ef796 100644 --- a/apps/meteor/tests/end-to-end/api/subscriptions.ts +++ b/apps/meteor/tests/end-to-end/api/subscriptions.ts @@ -394,6 +394,59 @@ describe('[Subscriptions]', () => { expect(res.body.subscription.tunread).to.deep.equal([threadId]); }); }); + + it('should mark a single thread as read when a tmid is provided', async () => { + await request + .post(api('chat.sendMessage')) + .set(threadUserCredentials) + .send({ + message: { + rid: testChannel._id, + msg: `@${adminUsername} making admin follow this thread`, + tmid: threadId, + }, + }); + + await request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: testChannel._id, + tmid: threadId, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('subscriptions.getOne')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.subscription).to.not.have.property('tunread'); + }); + }); + + it('should fail when the tmid does not belong to the provided room', (done) => { + void request + .post(api('subscriptions.read')) + .set(credentials) + .send({ + rid: testGroup._id, + tmid: threadId, + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + }) + .end(done); + }); }); }); diff --git a/apps/meteor/tests/end-to-end/api/users.ts b/apps/meteor/tests/end-to-end/api/users.ts index cd407c02760b8..62550d952de6b 100644 --- a/apps/meteor/tests/end-to-end/api/users.ts +++ b/apps/meteor/tests/end-to-end/api/users.ts @@ -3906,6 +3906,40 @@ describe('[Users]', () => { .end(done); }); + it('should persist the utcOffset preference on the user document', async () => { + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({ data: { utcOffset: 5 } }) + .expect(200) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('me')) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('utcOffset', 5); + }); + }); + + it('should fail when utcOffset is not a number', async () => { + await request + .post(api('users.setPreferences')) + .set(credentials) + .send({ data: { utcOffset: 'not-a-number' } }) + .expect(400) + .expect('Content-Type', 'application/json') + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'invalid-params'); + }); + }); + it('should return 401 when not authenticated', async () => { await request .post(api('users.setPreferences')) diff --git a/packages/rest-typings/src/v1/chat.ts b/packages/rest-typings/src/v1/chat.ts index 67f684eb3487b..575bf87ecadb8 100644 --- a/packages/rest-typings/src/v1/chat.ts +++ b/packages/rest-typings/src/v1/chat.ts @@ -930,9 +930,9 @@ export type ChatEndpoints = { }; '/v1/chat.delete': { POST: (params: ChatDelete) => { - _id: string; - ts: string; - message: Pick; + _id?: string; + ts?: string; + message?: Pick; }; }; '/v1/chat.react': { From 797dae450ced372abe4e2084f66508ea7ab3ecf9 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Thu, 11 Jun 2026 15:30:12 -0300 Subject: [PATCH 14/16] fix(chat): simplify error handling for message and file deletion --- apps/meteor/app/api/server/v1/chat.ts | 30 ++++----------------------- 1 file changed, 4 insertions(+), 26 deletions(-) diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 8de55ffe4f529..bb7ddeca39738 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -1,7 +1,7 @@ -import { Message, Upload } from '@rocket.chat/core-services'; +import { Message } from '@rocket.chat/core-services'; import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; import { MessageTypes } from '@rocket.chat/message-types'; -import { Messages, Users, Rooms, Subscriptions, Uploads } from '@rocket.chat/models'; +import { Messages, Users, Rooms, Subscriptions } from '@rocket.chat/models'; import { ajv, isChatReportMessageProps, @@ -37,7 +37,6 @@ import { getMessageHistory } from '../../../../server/publications/messages'; import { roomAccessAttributes } from '../../../authorization/server'; import { canAccessRoomAsync, canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; -import { FileUpload } from '../../../file-upload/server'; import { deleteMessageValidatingPermission } from '../../../lib/server/functions/deleteMessage'; import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage'; import { getSingleMessage } from '../../../lib/server/methods/getSingleMessage'; @@ -553,31 +552,10 @@ const chatEndpoints = API.v1 : await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); if (!msg) { - // The legacy `deleteFileMessage` method allowed removing an uploaded file - // that had no associated message by deleting it straight from the store, - // validating the upload-delete permission first. if ('fileId' in this.bodyParams) { - const user = await Users.findOneById(this.userId, { projection: { username: 1 } }); - if (!user) { - return API.v1.failure('User not found'); - } - - const file = await Uploads.findOneById(this.bodyParams.fileId, { - projection: { userId: 1, rid: 1, expiresAt: 1, uploadedAt: 1 }, - }); - if (!file) { - return API.v1.failure(`No file found with the file id of "${this.bodyParams.fileId}".`); - } - - if (!(await Upload.canDeleteFile(user, file, null))) { - return API.v1.failure('Unauthorized. You are not allowed to delete this file.'); - } - - await FileUpload.getStore('Uploads').deleteById(this.bodyParams.fileId); - return API.v1.success(); + return API.v1.failure(`No message found with the file id: "${this.bodyParams.fileId}".`); } - - return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`); + return API.v1.failure(`No message found with the id: "${this.bodyParams.msgId}".`); } if ('roomId' in this.bodyParams && this.bodyParams.roomId !== msg.rid) { From d12ff77c0266a496ace578ab0ae2035533bec6e1 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Fri, 12 Jun 2026 10:03:11 -0300 Subject: [PATCH 15/16] test: fix invalid assertions and restore chat.delete error message - chat.delete: revert error string from 'id:' back to 'id of' (regression) - chat.delete test: orphan file (no associated message) returns 400, not 200 - spotlight test: query a prefix so the usernames exclusion path runs (exact-username matches bypass the exclusion list) - subscriptions.read test: tunread is left as an empty array, not unset --- apps/meteor/app/api/server/v1/chat.ts | 5 ++++- apps/meteor/server/lib/spotlight.js | 4 ++++ apps/meteor/tests/end-to-end/api/chat.ts | 7 ++++--- apps/meteor/tests/end-to-end/api/miscellaneous.ts | 4 +++- apps/meteor/tests/end-to-end/api/subscriptions.ts | 3 ++- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index bb7ddeca39738..62378bd436e7a 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -546,6 +546,9 @@ const chatEndpoints = API.v1 }, }, async function action() { + // Deleting by fileId resolves the message that references the file and deletes it. + // An orphan upload (a file with no associated message) is not deletable through this + // endpoint and intentionally returns a failure below. const msg = 'fileId' in this.bodyParams ? await Messages.getMessageByFileId(this.bodyParams.fileId) @@ -555,7 +558,7 @@ const chatEndpoints = API.v1 if ('fileId' in this.bodyParams) { return API.v1.failure(`No message found with the file id: "${this.bodyParams.fileId}".`); } - return API.v1.failure(`No message found with the id: "${this.bodyParams.msgId}".`); + return API.v1.failure(`No message found with the id of "${this.bodyParams.msgId}".`); } if ('roomId' in this.bodyParams && this.bodyParams.roomId !== msg.rid) { diff --git a/apps/meteor/server/lib/spotlight.js b/apps/meteor/server/lib/spotlight.js index dc182ee9d3a6f..fc17f2ab641a8 100644 --- a/apps/meteor/server/lib/spotlight.js +++ b/apps/meteor/server/lib/spotlight.js @@ -226,6 +226,10 @@ export class Spotlight { }; // Exact match for username only + // TODO: these exact-match branches push the user without filtering against `usernames` + // (the exclusion list), so an exact username query bypasses the exclusion that the + // findByActiveUsersExcept paths below honor. Evaluate filtering exactMatch against + // `usernames` here so the exclusion applies uniformly. if (rid && canListInsiders) { const exactMatch = await Users.findOneByUsernameAndRoomIgnoringCase(text, rid, { projection: options.projection, diff --git a/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts index e7dbb5cba2b79..1792426f0b18c 100644 --- a/apps/meteor/tests/end-to-end/api/chat.ts +++ b/apps/meteor/tests/end-to-end/api/chat.ts @@ -2383,7 +2383,7 @@ describe('[Chat]', () => { }); }); - it('should remove an uploaded file that has no associated message', async () => { + it('should fail when the uploaded file has no associated message', async () => { const fileId = await uploadFile(); await request @@ -2391,9 +2391,10 @@ describe('[Chat]', () => { .set(credentials) .send({ fileId }) .expect('Content-Type', 'application/json') - .expect(200) + .expect(400) .expect((res) => { - expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `No message found with the file id: "${fileId}".`); }); }); diff --git a/apps/meteor/tests/end-to-end/api/miscellaneous.ts b/apps/meteor/tests/end-to-end/api/miscellaneous.ts index 3e4ad18ab1de8..83905e6fb4b2e 100644 --- a/apps/meteor/tests/end-to-end/api/miscellaneous.ts +++ b/apps/meteor/tests/end-to-end/api/miscellaneous.ts @@ -570,7 +570,9 @@ describe('miscellaneous', () => { void request .get(api('spotlight')) .query({ - query: `@${adminUsername}`, + // Use a non-exact (prefix) query so the regex search path runs; the exact-username + // match branch in Spotlight.searchUsers does not honor the usernames exclusion list. + query: adminUsername.slice(0, -2), usernames: adminUsername, }) .set(credentials) diff --git a/apps/meteor/tests/end-to-end/api/subscriptions.ts b/apps/meteor/tests/end-to-end/api/subscriptions.ts index 517f96c2ef796..638c247aa4d84 100644 --- a/apps/meteor/tests/end-to-end/api/subscriptions.ts +++ b/apps/meteor/tests/end-to-end/api/subscriptions.ts @@ -428,7 +428,8 @@ describe('[Subscriptions]', () => { .expect(200) .expect((res) => { expect(res.body).to.have.property('success', true); - expect(res.body.subscription).to.not.have.property('tunread'); + // removeUnreadThreadByRoomIdAndUserId only $pulls the tmid, leaving an empty array + expect(res.body.subscription).to.have.property('tunread').that.is.an('array').and.does.not.include(threadId); }); }); From e26ed03dfb9a1fb094471b98267b810ff2ccb30f Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 15 Jun 2026 16:45:13 -0300 Subject: [PATCH 16/16] chore: extract subscriptions.read tmid migration into separate PR The thread read-marker migration (subscriptions.read tmid endpoint + ThreadChat/useThreadMessagesQuery callers) moves to its own PR #40957. --- .changeset/ddp-migrate-batch2-callers.md | 3 +- .changeset/rest-subscriptions-read-tmid.md | 6 --- .../meteor/app/api/server/v1/subscriptions.ts | 14 +---- apps/meteor/app/threads/server/functions.ts | 34 +----------- .../Threads/components/ThreadChat.tsx | 8 +-- .../Threads/hooks/useThreadMessagesQuery.ts | 9 ++-- apps/meteor/server/methods/readThreads.ts | 36 ++++++++++--- .../tests/end-to-end/api/subscriptions.ts | 54 ------------------- .../src/v1/subscriptionsEndpoints.ts | 12 +---- 9 files changed, 44 insertions(+), 132 deletions(-) delete mode 100644 .changeset/rest-subscriptions-read-tmid.md diff --git a/.changeset/ddp-migrate-batch2-callers.md b/.changeset/ddp-migrate-batch2-callers.md index 5891f468028b9..9558b6c12b085 100644 --- a/.changeset/ddp-migrate-batch2-callers.md +++ b/.changeset/ddp-migrate-batch2-callers.md @@ -2,12 +2,11 @@ '@rocket.chat/meteor': patch --- -Migrate seven client DDP callers to their REST equivalents (the DDP methods stay registered on the server for external SDK/mobile clients, with a deprecation log pointing at the REST route until 9.0.0 removes them): +Migrate six client DDP callers to their REST equivalents (the DDP methods stay registered on the server for external SDK/mobile clients, with a deprecation log pointing at the REST route until 9.0.0 removes them): - `loadMissedMessages` β†’ `GET /v1/chat.syncMessages` - `joinRoom` β†’ `POST /v1/channels.join` (channel-only; non-`c` rooms now error via REST the same way they used to via DDP) - `userSetUtcOffset` β†’ `POST /v1/users.setPreferences` (new `utcOffset` field) - `deleteFileMessage` β†’ `POST /v1/chat.delete` (new `fileId` body shape) -- `readThreads` β†’ `POST /v1/subscriptions.read` (new `tmid` field) - `spotlight` β†’ `GET /v1/spotlight` (new `usernames` / `type` / `rid` query params) - `listCustomSounds` β†’ `GET /v1/custom-sounds.list` diff --git a/.changeset/rest-subscriptions-read-tmid.md b/.changeset/rest-subscriptions-read-tmid.md deleted file mode 100644 index 4439198dae548..0000000000000 --- a/.changeset/rest-subscriptions-read-tmid.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@rocket.chat/rest-typings': minor -'@rocket.chat/meteor': minor ---- - -`POST /v1/subscriptions.read` now accepts an optional `tmid` field. When provided the server marks the specific thread as read via `readThread({ user, room, tmid })` instead of marking the whole room. diff --git a/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index 1d51ee86746c3..9b69aa5478d1c 100644 --- a/apps/meteor/app/api/server/v1/subscriptions.ts +++ b/apps/meteor/app/api/server/v1/subscriptions.ts @@ -1,5 +1,5 @@ import type { ISubscription } from '@rocket.chat/core-typings'; -import { Messages, Rooms, Subscriptions } from '@rocket.chat/models'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; import { ajv, isSubscriptionsGetProps, @@ -14,7 +14,6 @@ import { Meteor } from 'meteor/meteor'; import { readMessages } from '../../../../server/lib/readMessages'; import { getSubscriptions } from '../../../../server/publications/subscription'; import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages'; -import { readThreadMethod } from '../../../threads/server/functions'; import { API } from '../api'; const subscriptionsGetResponseSchema = ajv.compile<{ @@ -140,7 +139,7 @@ API.v1.post( }, }, async function action() { - const { readThreads = false, tmid } = this.bodyParams; + const { readThreads = false } = this.bodyParams; const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId; const room = await Rooms.findOneById(roomId); @@ -148,15 +147,6 @@ API.v1.post( throw new Error('error-invalid-subscription'); } - if (tmid) { - const thread = await Messages.findOneById(tmid); - if (thread?.rid !== room._id) { - throw new Error('error-invalid-thread'); - } - await readThreadMethod({ user: this.user, tmid }); - return API.v1.success(); - } - await readMessages(room, this.userId, readThreads); return API.v1.success(); diff --git a/apps/meteor/app/threads/server/functions.ts b/apps/meteor/app/threads/server/functions.ts index f7dd0f117a28c..4c5db1ddf70b5 100644 --- a/apps/meteor/app/threads/server/functions.ts +++ b/apps/meteor/app/threads/server/functions.ts @@ -1,16 +1,13 @@ import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; -import { Messages, Rooms, Subscriptions, NotificationQueue } from '@rocket.chat/models'; -import { Meteor } from 'meteor/meteor'; +import { Messages, Subscriptions, NotificationQueue } from '@rocket.chat/models'; import { callbacks } from '../../../server/lib/callbacks'; -import { canAccessRoomAsync } from '../../authorization/server'; import { notifyOnSubscriptionChangedByRoomIdAndUserIds, notifyOnSubscriptionChangedByRoomIdAndUserId, } from '../../lib/server/lib/notifyListener'; import { getMentions, getUserIdsFromHighlights } from '../../lib/server/lib/notifyUsersOnMessage'; -import { settings } from '../../settings/server'; export async function reply({ tmid }: { tmid?: string }, message: IMessage, parentMessage: IMessage, followers: string[]) { if (!tmid || isEditedMessage(message)) { @@ -101,32 +98,3 @@ export const readThread = async ({ user, room, tmid }: { user: IUser; room: IRoo callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid }); }; - -/** - * Marks a thread as read for a user, replicating the full behavior of the - * legacy `readThreads` DDP method: it validates that threads are enabled, that - * the thread and its room exist, that the user can access the room, and runs - * the `beforeReadMessages` callback before clearing the thread unread state. - */ -export const readThreadMethod = async ({ user, tmid }: { user: IUser; tmid: IMessage['_id'] }) => { - if (!settings.get('Threads_enabled')) { - throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { method: 'readThreads' }); - } - - const thread = await Messages.findOneById(tmid); - if (!thread) { - return; - } - - const room = await Rooms.findOneById(thread.rid); - if (!room) { - throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'readThreads' }); - } - - if (!(await canAccessRoomAsync(room, user))) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'readThreads' }); - } - - await callbacks.run('beforeReadMessages', thread.rid, user._id); - await readThread({ user, room, tmid }); -}; diff --git a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx index 65dbaac381f0b..079fbdde01da8 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx +++ b/apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx @@ -2,7 +2,7 @@ import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings'; import { isEditedMessage } from '@rocket.chat/core-typings'; import { Box, CheckBox, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage'; import { clientCallbacks, ContextualbarContent } from '@rocket.chat/ui-client'; -import { useEndpoint, useTranslation, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts'; +import { useMethod, useTranslation, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts'; import { useState, useEffect, useCallback, useId } from 'react'; import ThreadMessageList from './ThreadMessageList'; @@ -62,7 +62,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { }, [chat?.messageEditing]); const room = useRoom(); - const markThreadRead = useEndpoint('POST', '/v1/subscriptions.read'); + const readThreads = useMethod('readThreads'); useEffect(() => { clientCallbacks.add( 'streamNewMessage', @@ -71,7 +71,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { return; } - void markThreadRead({ rid: room._id, tmid: mainMessage._id }); + readThreads(mainMessage._id); }, clientCallbacks.priority.MEDIUM, `thread-${room._id}`, @@ -80,7 +80,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => { return () => { clientCallbacks.remove('streamNewMessage', `thread-${room._id}`); }; - }, [mainMessage._id, markThreadRead, room._id]); + }, [mainMessage._id, readThreads, room._id]); const subscription = useRoomSubscription(); const sendToChannelID = useId(); diff --git a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts index f45d151d78cb0..4a6e1e7a2202b 100644 --- a/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts +++ b/apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts @@ -1,5 +1,5 @@ import { isThreadMessage, type IMessage, type IRoom, type IThreadMainMessage, type IThreadMessage } from '@rocket.chat/core-typings'; -import { useEndpoint, useStream } from '@rocket.chat/ui-contexts'; +import { useEndpoint, useMethod, useStream } from '@rocket.chat/ui-contexts'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef } from 'react'; @@ -26,7 +26,10 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR const queryClient = useQueryClient(); const queryKey = roomsQueryKeys.threadMessages(roomId, tmid); const getThreadMessages = useEndpoint('GET', '/v1/chat.getThreadMessages'); - const markThreadRead = useEndpoint('POST', '/v1/subscriptions.read'); + // REST has no per-thread read-marker endpoint yet; fall back to the + // `readThreads` DDP method so the side effect that DDP getThreadMessages + // used to do server-side keeps happening for callers. + const readThreads = useMethod('readThreads'); const subscribeToRoomMessages = useStream('room-messages'); const subscribeToNotifyRoom = useStream('notify-room'); @@ -108,7 +111,7 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR const cachedMessages = queryClient.getQueryData(queryKey) || []; const { messages } = await getThreadMessages({ tmid }); - void markThreadRead({ rid: roomId, tmid }).catch(() => undefined); + void Promise.resolve(readThreads(tmid)).catch(() => undefined); const filtered = messages .map((m) => mapMessageFromApi(m)) .filter((msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true); diff --git a/apps/meteor/server/methods/readThreads.ts b/apps/meteor/server/methods/readThreads.ts index 74c3d03293f1b..c1a6fe3807350 100644 --- a/apps/meteor/server/methods/readThreads.ts +++ b/apps/meteor/server/methods/readThreads.ts @@ -1,10 +1,13 @@ import type { IMessage, IUser } from '@rocket.chat/core-typings'; import type { ServerMethods } from '@rocket.chat/ddp-client'; +import { Messages, Rooms } from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; -import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; -import { readThreadMethod } from '../../app/threads/server/functions'; +import { canAccessRoomAsync } from '../../app/authorization/server'; +import { settings } from '../../app/settings/server'; +import { readThread } from '../../app/threads/server/functions'; +import { callbacks } from '../lib/callbacks'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -15,14 +18,33 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async readThreads(tmid) { - methodDeprecationLogger.method('readThreads', '9.0.0', '/v1/subscriptions.read'); check(tmid, String); - const user = (await Meteor.userAsync()) as IUser | null; - if (!user) { - throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'readThreads' }); + if (!Meteor.userId() || !settings.get('Threads_enabled')) { + throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { + method: 'getThreadMessages', + }); } - await readThreadMethod({ user, tmid }); + const thread = await Messages.findOneById(tmid); + if (!thread) { + return; + } + + const user = (await Meteor.userAsync()) ?? undefined; + + const room = await Rooms.findOneById(thread.rid); + if (!room) { + throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'getThreadMessages' }); + } + + if (!(await canAccessRoomAsync(room, user))) { + throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getThreadMessages' }); + } + + await callbacks.run('beforeReadMessages', thread.rid, user?._id); + if (user?._id) { + await readThread({ user: user as IUser, room, tmid }); + } }, }); diff --git a/apps/meteor/tests/end-to-end/api/subscriptions.ts b/apps/meteor/tests/end-to-end/api/subscriptions.ts index 638c247aa4d84..7379e59606aab 100644 --- a/apps/meteor/tests/end-to-end/api/subscriptions.ts +++ b/apps/meteor/tests/end-to-end/api/subscriptions.ts @@ -394,60 +394,6 @@ describe('[Subscriptions]', () => { expect(res.body.subscription.tunread).to.deep.equal([threadId]); }); }); - - it('should mark a single thread as read when a tmid is provided', async () => { - await request - .post(api('chat.sendMessage')) - .set(threadUserCredentials) - .send({ - message: { - rid: testChannel._id, - msg: `@${adminUsername} making admin follow this thread`, - tmid: threadId, - }, - }); - - await request - .post(api('subscriptions.read')) - .set(credentials) - .send({ - rid: testChannel._id, - tmid: threadId, - }) - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - - await request - .get(api('subscriptions.getOne')) - .set(credentials) - .query({ - roomId: testChannel._id, - }) - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - // removeUnreadThreadByRoomIdAndUserId only $pulls the tmid, leaving an empty array - expect(res.body.subscription).to.have.property('tunread').that.is.an('array').and.does.not.include(threadId); - }); - }); - - it('should fail when the tmid does not belong to the provided room', (done) => { - void request - .post(api('subscriptions.read')) - .set(credentials) - .send({ - rid: testGroup._id, - tmid: threadId, - }) - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error'); - }) - .end(done); - }); }); }); diff --git a/packages/rest-typings/src/v1/subscriptionsEndpoints.ts b/packages/rest-typings/src/v1/subscriptionsEndpoints.ts index 46026000beb2e..d188f4d594f69 100644 --- a/packages/rest-typings/src/v1/subscriptionsEndpoints.ts +++ b/packages/rest-typings/src/v1/subscriptionsEndpoints.ts @@ -6,9 +6,7 @@ type SubscriptionsGet = { updatedSince?: string }; type SubscriptionsGetOne = { roomId: IRoom['_id'] }; -type SubscriptionsRead = - | { rid: IRoom['_id']; readThreads?: boolean; tmid?: IMessage['_id'] } - | { roomId: IRoom['_id']; readThreads?: boolean; tmid?: IMessage['_id'] }; +type SubscriptionsRead = { rid: IRoom['_id']; readThreads?: boolean } | { roomId: IRoom['_id']; readThreads?: boolean }; type SubscriptionsUnread = { roomId: IRoom['_id'] } | { firstUnreadMessage: Pick }; @@ -51,10 +49,6 @@ const SubscriptionsReadSchema = { type: 'boolean', nullable: true, }, - tmid: { - type: 'string', - nullable: true, - }, }, required: ['rid'], additionalProperties: false, @@ -69,10 +63,6 @@ const SubscriptionsReadSchema = { type: 'boolean', nullable: true, }, - tmid: { - type: 'string', - nullable: true, - }, }, required: ['roomId'], additionalProperties: false,