From bc510f5ebfcb80ec4be3cc95a699ca08be44a3ca Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 15 Jun 2026 15:41:37 -0300 Subject: [PATCH 1/3] chore: reduce room opening latency in message history load (#40954) --- .changeset/swift-rooms-load.md | 5 ++ .../server/functions/loadMessageHistory.ts | 50 +++++++++---------- .../ui-utils/client/lib/RoomHistoryManager.ts | 18 +++---- apps/meteor/server/methods/loadHistory.ts | 6 +-- 4 files changed, 41 insertions(+), 38 deletions(-) create mode 100644 .changeset/swift-rooms-load.md diff --git a/.changeset/swift-rooms-load.md b/.changeset/swift-rooms-load.md new file mode 100644 index 0000000000000..534b1a1687d48 --- /dev/null +++ b/.changeset/swift-rooms-load.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Speeds up room opening by removing redundant work in the message history load. On the client, the prefetched first history batch no longer blocks on the message-list DOM before rendering, and the history pager no longer fires an extra `loadHistory` round trip just to reach a full page of visible messages when the latest page contains thread replies. On the server, `loadHistory` reuses the already-fetched room document instead of querying it twice, and runs message normalization and the unread (first-unread + count) queries concurrently instead of sequentially. diff --git a/apps/meteor/app/lib/server/functions/loadMessageHistory.ts b/apps/meteor/app/lib/server/functions/loadMessageHistory.ts index 5addbd896889b..6361c40ce99f3 100644 --- a/apps/meteor/app/lib/server/functions/loadMessageHistory.ts +++ b/apps/meteor/app/lib/server/functions/loadMessageHistory.ts @@ -1,4 +1,4 @@ -import type { IMessage, MessageTypesValues } from '@rocket.chat/core-typings'; +import type { IMessage, IRoom, MessageTypesValues } from '@rocket.chat/core-typings'; import { Messages, Rooms } from '@rocket.chat/models'; import type { FindOptions } from 'mongodb'; @@ -14,6 +14,7 @@ export async function loadMessageHistory({ ls, showThreadMessages = true, offset = 0, + room: providedRoom, }: { // userId is undefined if user is reading anonymously userId?: string; @@ -23,8 +24,9 @@ export async function loadMessageHistory({ ls?: string | Date; showThreadMessages?: boolean; offset?: number; + room?: IRoom; }) { - const room = await Rooms.findOneById(rid, { projection: { sysMes: 1 } }); + const room = providedRoom ?? (await Rooms.findOneById(rid, { projection: { sysMes: 1 } })); if (!room) { throw new Error('error-invalid-room'); @@ -51,46 +53,42 @@ export async function loadMessageHistory({ showThreadMessages, ).toArray() : await Messages.findVisibleByRoomIdNotContainingTypes(rid, hiddenMessageTypes, options, showThreadMessages).toArray(); - const messages = await normalizeMessagesForUser(records, userId); - let unreadNotLoaded = 0; - let firstUnread; - if (ls) { - const firstMessage = messages[messages.length - 1]; + const firstMessage = records[records.length - 1]; + const lastSeen = ls ? new Date(ls) : undefined; + const hasValidLastSeen = lastSeen !== undefined && !Number.isNaN(lastSeen.getTime()); - const lastSeen = new Date(ls); + const computeUnread = async (): Promise<{ firstUnread: IMessage | undefined; unreadNotLoaded: number }> => { + if (!hasValidLastSeen || !lastSeen || !firstMessage || !(new Date(firstMessage.ts) > lastSeen)) { + return { firstUnread: undefined, unreadNotLoaded: 0 }; + } - if (firstMessage && new Date(firstMessage.ts) > lastSeen) { - const unreadMessages = Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( + const [firstUnreadRecords, unreadNotLoaded] = await Promise.all([ + Messages.findVisibleByRoomIdBetweenTimestampsNotContainingTypes( rid, lastSeen, firstMessage.ts, hiddenMessageTypes, - { - limit: 1, - sort: { - ts: 1, - }, - }, + { limit: 1, sort: { ts: 1 } }, showThreadMessages, - ); - - const totalCursor = await Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes( + ).toArray(), + Messages.countVisibleByRoomIdBetweenTimestampsNotContainingTypes( rid, lastSeen, firstMessage.ts, hiddenMessageTypes, showThreadMessages, - ); + ), + ]); - firstUnread = (await unreadMessages.toArray())[0]; - unreadNotLoaded = totalCursor; - } - } + return { firstUnread: firstUnreadRecords[0], unreadNotLoaded }; + }; + + const [messages, unread] = await Promise.all([normalizeMessagesForUser(records, userId), computeUnread()]); return { messages, - firstUnread, - unreadNotLoaded, + firstUnread: unread.firstUnread, + unreadNotLoaded: unread.unreadNotLoaded, }; } diff --git a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts index 90f36fb21fc31..8c61f98469232 100644 --- a/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts +++ b/apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts @@ -7,7 +7,6 @@ import { onClientMessageReceived } from '../../../../client/lib/onClientMessageR import { getUserId } from '../../../../client/lib/user'; import { callWithErrorHandling } from '../../../../client/lib/utils/callWithErrorHandling'; import { getConfig } from '../../../../client/lib/utils/getConfig'; -import { waitForElement } from '../../../../client/lib/utils/waitForElement'; import { Messages, Subscriptions } from '../../../../client/stores'; import { getUserPreference } from '../../../utils/client'; @@ -175,12 +174,13 @@ class RoomHistoryManagerClass extends Emitter { room.oldestTs = messages[messages.length - 1].ts; } - const wrapper = await waitForElement('.messages-box .wrapper [data-overlayscrollbars-viewport]'); - - room.scroll = { - scrollHeight: wrapper.scrollHeight, - scrollTop: wrapper.scrollTop, - }; + const wrapper = document.querySelector('.messages-box .wrapper [data-overlayscrollbars-viewport]'); + if (wrapper) { + room.scroll = { + scrollHeight: wrapper.scrollHeight, + scrollTop: wrapper.scrollTop, + }; + } await upsertMessageBulk({ msgs: messages.filter((msg) => msg.t !== 'command'), @@ -193,7 +193,7 @@ class RoomHistoryManagerClass extends Emitter { room.loaded = 0; } - const visibleMessages = messages.filter((msg) => !msg.tmid || showThreadsInMainChannel || msg.tshow); + const visibleMessages = messages.filter((msg) => msg.t !== 'command' && (!msg.tmid || showThreadsInMainChannel || msg.tshow)); room.loaded += visibleMessages.length; @@ -201,7 +201,7 @@ class RoomHistoryManagerClass extends Emitter { this.updateRoom(rid, { hasMore: false }); } - if (room.hasMore && (visibleMessages.length === 0 || room.loaded < limit)) { + if (room.hasMore && visibleMessages.length === 0) { return this.getMore(rid); } diff --git a/apps/meteor/server/methods/loadHistory.ts b/apps/meteor/server/methods/loadHistory.ts index d2d7342fb712f..b465d2a167518 100644 --- a/apps/meteor/server/methods/loadHistory.ts +++ b/apps/meteor/server/methods/loadHistory.ts @@ -39,7 +39,7 @@ Meteor.methods({ }); } - const room = await Rooms.findOneById(rid, { projection: { ...roomAccessAttributes, t: 1 } }); + const room = await Rooms.findOneById(rid, { projection: { ...roomAccessAttributes, t: 1, sysMes: 1 } }); if (!room) { return false; } @@ -51,7 +51,7 @@ Meteor.methods({ // if fromId is undefined and it passed the previous check, the user is reading anonymously if (!fromUser) { - return loadMessageHistory({ rid, end, limit, ls, showThreadMessages }); + return loadMessageHistory({ rid, end, limit, ls, showThreadMessages, room }); } const canPreview = await hasPermissionAsync(fromUser._id, 'preview-c-room'); @@ -60,6 +60,6 @@ Meteor.methods({ return false; } - return loadMessageHistory({ userId: fromUser._id, rid, end, limit, ls, showThreadMessages }); + return loadMessageHistory({ userId: fromUser._id, rid, end, limit, ls, showThreadMessages, room }); }, }); From 9a36221f1fbf5ca417325204637c9f32fe760443 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 15 Jun 2026 17:35:56 -0300 Subject: [PATCH 2/3] feat: extend 4 REST endpoints + migrate 7 DDP callers (#40711) --- .changeset/ddp-migrate-batch2-callers.md | 12 +++ .changeset/rest-chat-delete-by-fileid.md | 6 ++ .../rest-spotlight-params-and-anonymous.md | 11 +++ .../rest-users-setpreferences-utcoffset.md | 6 ++ apps/meteor/app/api/server/v1/chat.ts | 19 +++-- apps/meteor/app/api/server/v1/misc.ts | 25 +++++-- .../server/methods/listCustomSounds.ts | 3 + .../meteor/app/lib/server/methods/joinRoom.ts | 3 + apps/meteor/client/hooks/useJoinRoom.ts | 12 +-- apps/meteor/client/lib/chats/data.ts | 5 +- .../NavBarSearch/hooks/useSearchItems.ts | 10 ++- .../CustomSoundProvider.tsx | 23 ++++-- .../CustomSoundProvider/lib/helpers.ts | 2 +- apps/meteor/client/startup/startup.ts | 4 +- .../RoomFiles/hooks/useDeleteFile.tsx | 6 +- .../room/providers/ComposerPopupProvider.tsx | 17 ++++- .../views/root/hooks/useLoadMissedMessages.ts | 31 +++++++- apps/meteor/server/lib/spotlight.js | 4 + .../server/methods/deleteFileMessage.ts | 2 + .../server/methods/loadMissedMessages.ts | 2 + .../server/methods/saveUserPreferences.ts | 8 ++ .../meteor/server/methods/userSetUtcOffset.ts | 3 + apps/meteor/server/publications/spotlight.ts | 2 + apps/meteor/tests/end-to-end/api/chat.ts | 74 +++++++++++++++++++ .../tests/end-to-end/api/miscellaneous.ts | 51 +++++++++++++ apps/meteor/tests/end-to-end/api/users.ts | 34 +++++++++ packages/rest-typings/src/v1/chat.ts | 26 ++++--- packages/rest-typings/src/v1/misc.ts | 46 +++++++++++- .../v1/users/UsersSetPreferenceParamsPOST.ts | 5 ++ 29 files changed, 402 insertions(+), 50 deletions(-) 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-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..9558b6c12b085 --- /dev/null +++ b/.changeset/ddp-migrate-batch2-callers.md @@ -0,0 +1,12 @@ +--- +'@rocket.chat/meteor': patch +--- + +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) +- `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-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. diff --git a/apps/meteor/app/api/server/v1/chat.ts b/apps/meteor/app/api/server/v1/chat.ts index 383f4312c53b5..62378bd436e7a 100644 --- a/apps/meteor/app/api/server/v1/chat.ts +++ b/apps/meteor/app/api/server/v1/chat.ts @@ -521,7 +521,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 +538,7 @@ const chatEndpoints = API.v1 }, success: { type: 'boolean', enum: [true] }, }, - required: ['_id', 'ts', 'message', 'success'], + required: ['success'], additionalProperties: false, }), 400: validateBadRequestErrorResponse, @@ -546,13 +546,22 @@ const chatEndpoints = API.v1 }, }, async function action() { - const msg = await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); + // 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) + : await Messages.findOneById(this.bodyParams.msgId, { projection: { u: 1, rid: 1 } }); if (!msg) { + 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 of "${this.bodyParams.msgId}".`); } - 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.'); } @@ -576,7 +585,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/misc.ts b/apps/meteor/app/api/server/v1/misc.ts index d7b0be701a3f5..44ebb96478b23 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,11 @@ API.v1.get( ); const spotlightResponseSchema = ajv.compile<{ - users: Pick[]; + users: (Pick & + Partial> & { + nickname?: string; + outside?: boolean; + })[]; rooms: Pick[]; }>({ type: 'object', @@ -356,7 +362,7 @@ const spotlightResponseSchema = ajv.compile<{ statusText: { type: 'string' }, avatarETag: { type: 'string' }, }, - required: ['_id', 'name', 'username', 'status'], + required: ['_id', 'name', 'username'], additionalProperties: true, }, }, @@ -383,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, @@ -392,9 +401,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/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/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(); diff --git a/apps/meteor/client/hooks/useJoinRoom.ts b/apps/meteor/client/hooks/useJoinRoom.ts index 7204d378ca36f..3685693222979 100644 --- a/apps/meteor/client/hooks/useJoinRoom.ts +++ b/apps/meteor/client/hooks/useJoinRoom.ts @@ -1,9 +1,7 @@ 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'; - type UseJoinRoomMutationFunctionProps = { rid: IRoom['_id']; reference: string; @@ -13,11 +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) => { - await sdk.call('joinRoom', rid); - + await joinChannel({ roomId: rid }); return { reference, type }; }, onSuccess: (data) => { 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 => 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/providers/CustomSoundProvider/CustomSoundProvider.tsx b/apps/meteor/client/providers/CustomSoundProvider/CustomSoundProvider.tsx index 9cda0dc4cb08f..1b90df8444fc2 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,24 @@ const CustomSoundProvider = ({ children }: CustomSoundProviderProps) => { const { data: list } = useQuery({ queryFn: async (): Promise[]> => { - const customSoundsList = await sdk.call('listCustomSounds'); - if (!customSoundsList.length) { + // `/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; } - 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 }); }; 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/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/client/views/root/hooks/useLoadMissedMessages.ts b/apps/meteor/client/views/root/hooks/useLoadMissedMessages.ts index 341a7a59c68dd..631a7c107e0a9 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,11 +22,33 @@ 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 }))); + // `/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/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/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/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); 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/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts index 8cad3dee79251..1792426f0b18c 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,79 @@ 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 fail when the uploaded file 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(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', `No message found with the file id: "${fileId}".`); + }); + }); + + 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..83905e6fb4b2e 100644 --- a/apps/meteor/tests/end-to-end/api/miscellaneous.ts +++ b/apps/meteor/tests/end-to-end/api/miscellaneous.ts @@ -549,6 +549,57 @@ 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({ + // 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) + .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/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 8e8d52479e504..575bf87ecadb8 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, }; @@ -922,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': { diff --git a/packages/rest-typings/src/v1/misc.ts b/packages/rest-typings/src/v1/misc.ts index 5df161c0e1c33..7224e163fbaea 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' | '_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/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 ba704220fba7d040f4e5b6850985a1955e59f360 Mon Sep 17 00:00:00 2001 From: Yasmim Nagat <117310290+yasnagat@users.noreply.github.com> Date: Mon, 15 Jun 2026 18:58:55 -0300 Subject: [PATCH 3/3] chore(deps): bump `qs`, `react-router`, `uuid` (#40950) Co-authored-by: Julio Araujo --- apps/meteor/package.json | 2 +- apps/uikit-playground/package.json | 2 +- package.json | 4 +- packages/http-router/package.json | 2 +- yarn.lock | 144 ++++++++++++++++++----------- 5 files changed, 94 insertions(+), 60 deletions(-) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 05097ad145eeb..b5e20190c936e 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -269,7 +269,7 @@ "prometheus-gc-stats": "^1.1.0", "proxy-from-env": "^1.1.0", "psl": "^1.10.0", - "qs": "^6.14.2", + "qs": "^6.15.2", "query-string": "^7.1.3", "queue-fifo": "^0.2.6", "re-resizable": "^6.10.4", diff --git a/apps/uikit-playground/package.json b/apps/uikit-playground/package.json index 5b14a723e1dbd..e542f9314115d 100644 --- a/apps/uikit-playground/package.json +++ b/apps/uikit-playground/package.json @@ -34,7 +34,7 @@ "rc-scrollbars": "^1.1.6", "react": "~18.3.1", "react-dom": "~18.3.1", - "react-router-dom": "^6.30.3", + "react-router-dom": "^6.30.4", "react-split-pane": "^0.1.92", "react-virtuoso": "~4.12.8", "reactflow": "^11.11.4" diff --git a/package.json b/package.json index 164f4ca2c2fc8..3d5f77fc8723e 100644 --- a/package.json +++ b/package.json @@ -164,7 +164,9 @@ "@opentelemetry/exporter-logs-otlp-grpc/@grpc/grpc-js": "^1.14.4", "@opentelemetry/exporter-metrics-otlp-grpc/@grpc/grpc-js": "^1.14.4", "@opentelemetry/exporter-trace-otlp-grpc/@grpc/grpc-js": "^1.14.4", - "@opentelemetry/otlp-grpc-exporter-base/@grpc/grpc-js": "^1.14.4" + "@opentelemetry/otlp-grpc-exporter-base/@grpc/grpc-js": "^1.14.4", + "qs": "6.15.2", + "uuid@npm:13": "13.0.1" }, "dependencies": { "@types/stream-buffers": "^3.0.8", diff --git a/packages/http-router/package.json b/packages/http-router/package.json index fa39da6e045ff..01269f6f842c1 100644 --- a/packages/http-router/package.json +++ b/packages/http-router/package.json @@ -23,7 +23,7 @@ "ajv": "^8.17.1", "express": "^4.21.2", "hono": "4.12.23", - "qs": "^6.14.2" + "qs": "^6.15.2" }, "devDependencies": { "@rocket.chat/jest-presets": "workspace:^", diff --git a/yarn.lock b/yarn.lock index 3dc8f6191bc98..ef99a9b8992f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8900,10 +8900,10 @@ __metadata: languageName: node linkType: hard -"@remix-run/router@npm:1.23.2": - version: 1.23.2 - resolution: "@remix-run/router@npm:1.23.2" - checksum: 10/50eb497854881bbd2e1016d4eb83c935ecd618e1c3888b74718851317e3b04edbaae9fe1baa49ec08c5c52cfe7118f4664e37144813d9500f45f922d6602a782 +"@remix-run/router@npm:1.23.3": + version: 1.23.3 + resolution: "@remix-run/router@npm:1.23.3" + checksum: 10/a12ae9994bf017d47392ce2be24252b4e2281f4b27eac78f0e9b48afa5f522be9c0ec64b85db013ded1fff140d9512b97ac8ea5dd182138dcd6d3ded3136cbf6 languageName: node linkType: hard @@ -9595,7 +9595,7 @@ __metadata: express: "npm:^4.21.2" hono: "npm:4.12.23" jest: "npm:~30.2.0" - qs: "npm:^6.14.2" + qs: "npm:^6.15.2" supertest: "npm:~7.1.4" ts-jest: "npm:~29.4.9" typescript: "npm:~5.9.3" @@ -10245,7 +10245,7 @@ __metadata: proxy-from-env: "npm:^1.1.0" proxyquire: "npm:^2.1.3" psl: "npm:^1.10.0" - qs: "npm:^6.14.2" + qs: "npm:^6.15.2" query-string: "npm:^7.1.3" queue-fifo: "npm:^0.2.6" raw-loader: "npm:~4.0.2" @@ -11263,7 +11263,7 @@ __metadata: rc-scrollbars: "npm:^1.1.6" react: "npm:~18.3.1" react-dom: "npm:~18.3.1" - react-router-dom: "npm:^6.30.3" + react-router-dom: "npm:^6.30.4" react-split-pane: "npm:^0.1.92" react-virtuoso: "npm:~4.12.8" reactflow: "npm:^11.11.4" @@ -17384,7 +17384,7 @@ __metadata: languageName: node linkType: hard -"body-parser@npm:1.20.4, body-parser@npm:^1.20.4, body-parser@npm:~1.20.3": +"body-parser@npm:1.20.4, body-parser@npm:~1.20.3": version: 1.20.4 resolution: "body-parser@npm:1.20.4" dependencies: @@ -17404,6 +17404,26 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^1.20.4, body-parser@npm:~1.20.5": + version: 1.20.5 + resolution: "body-parser@npm:1.20.5" + dependencies: + bytes: "npm:~3.1.2" + content-type: "npm:~1.0.5" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + destroy: "npm:~1.2.0" + http-errors: "npm:~2.0.1" + iconv-lite: "npm:~0.4.24" + on-finished: "npm:~2.4.1" + qs: "npm:~6.15.1" + raw-body: "npm:~2.5.3" + type-is: "npm:~1.6.18" + unpipe: "npm:~1.0.0" + checksum: 10/3ec787c0d23b16542972226ee352ed917ae404bf862b6783040e8cfc994f165432f6d48e9341ef57f489c667c880f9c5174fad559c482607f83f4db7f412de3a + languageName: node + linkType: hard + "bonjour-service@npm:^1.2.1": version: 1.2.1 resolution: "bonjour-service@npm:1.2.1" @@ -21982,7 +22002,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^4.21.0, express@npm:^4.21.2": +"express@npm:^4.21.0": version: 4.21.2 resolution: "express@npm:4.21.2" dependencies: @@ -22021,6 +22041,45 @@ __metadata: languageName: node linkType: hard +"express@npm:^4.21.2": + version: 4.22.2 + resolution: "express@npm:4.22.2" + dependencies: + accepts: "npm:~1.3.8" + array-flatten: "npm:1.1.1" + body-parser: "npm:~1.20.5" + content-disposition: "npm:~0.5.4" + content-type: "npm:~1.0.4" + cookie: "npm:~0.7.1" + cookie-signature: "npm:~1.0.6" + debug: "npm:2.6.9" + depd: "npm:2.0.0" + encodeurl: "npm:~2.0.0" + escape-html: "npm:~1.0.3" + etag: "npm:~1.8.1" + finalhandler: "npm:~1.3.1" + fresh: "npm:~0.5.2" + http-errors: "npm:~2.0.0" + merge-descriptors: "npm:1.0.3" + methods: "npm:~1.1.2" + on-finished: "npm:~2.4.1" + parseurl: "npm:~1.3.3" + path-to-regexp: "npm:~0.1.12" + proxy-addr: "npm:~2.0.7" + qs: "npm:~6.15.1" + range-parser: "npm:~1.2.1" + safe-buffer: "npm:5.2.1" + send: "npm:~0.19.0" + serve-static: "npm:~1.16.2" + setprototypeof: "npm:1.2.0" + statuses: "npm:~2.0.1" + type-is: "npm:~1.6.18" + utils-merge: "npm:1.0.1" + vary: "npm:~1.1.2" + checksum: 10/defeef5f1cda5bf5ee4a6b35252563552d8d97fab1d29f502e45ef64316f5d02f49739d7c8a79e425613af3a542d0e3cd8cf7e85671ce2ddd592197abcf5acc3 + languageName: node + linkType: hard + "express@npm:^4.22.1": version: 4.22.1 resolution: "express@npm:4.22.1" @@ -31561,39 +31620,12 @@ __metadata: languageName: node linkType: hard -"qs@npm:6.13.0": - version: 6.13.0 - resolution: "qs@npm:6.13.0" - dependencies: - side-channel: "npm:^1.0.6" - checksum: 10/f548b376e685553d12e461409f0d6e5c59ec7c7d76f308e2a888fd9db3e0c5e89902bedd0754db3a9038eda5f27da2331a6f019c8517dc5e0a16b3c9a6e9cef8 - languageName: node - linkType: hard - -"qs@npm:^6.11.2, qs@npm:^6.12.3": - version: 6.14.0 - resolution: "qs@npm:6.14.0" - dependencies: - side-channel: "npm:^1.1.0" - checksum: 10/a60e49bbd51c935a8a4759e7505677b122e23bf392d6535b8fc31c1e447acba2c901235ecb192764013cd2781723dc1f61978b5fdd93cc31d7043d31cdc01974 - languageName: node - linkType: hard - -"qs@npm:^6.14.2, qs@npm:^6.9.4": - version: 6.15.1 - resolution: "qs@npm:6.15.1" - dependencies: - side-channel: "npm:^1.1.0" - checksum: 10/ec10b9957446b3f4a38000940f6374720b4e2985209b89df197066038c951472ea24cd98d6bc6df73a0cbec75bc056f638032e3fb447345017ff7e0f0a2693ac - languageName: node - linkType: hard - -"qs@npm:~6.14.0": - version: 6.14.1 - resolution: "qs@npm:6.14.1" +"qs@npm:6.15.2": + version: 6.15.2 + resolution: "qs@npm:6.15.2" dependencies: side-channel: "npm:^1.1.0" - checksum: 10/34b5ab00a910df432d55180ef39c1d1375e550f098b5ec153b41787f1a6a6d7e5f9495593c3b112b77dbc6709d0ae18e55b82847a4c2bbbb0de1e8ccbb1794c5 + checksum: 10/7a934b2dba40654cf9878dab7c1e7ad56c6186265509727fbece67dadc38fd5d67715b61b6ef938e5a0475faeee701d18ec0a7ce420ad64367caad7f1bdb66e2 languageName: node linkType: hard @@ -32111,27 +32143,27 @@ __metadata: languageName: node linkType: hard -"react-router-dom@npm:^6.30.3": - version: 6.30.3 - resolution: "react-router-dom@npm:6.30.3" +"react-router-dom@npm:^6.30.4": + version: 6.30.4 + resolution: "react-router-dom@npm:6.30.4" dependencies: - "@remix-run/router": "npm:1.23.2" - react-router: "npm:6.30.3" + "@remix-run/router": "npm:1.23.3" + react-router: "npm:6.30.4" peerDependencies: react: ">=16.8" react-dom: ">=16.8" - checksum: 10/db974d801070e9967a076b31edca902e127793e02dc79f364461b94e81846a588c241d72e069f5b586b4a90ffd99798f5cb97753ac9d22fe90afa6dc008ab520 + checksum: 10/e220abac766bb9bcc56a99b78d30d99745cae3618f84baa92bd6ec481eccb6afb12c724d49dc68e6f870b70ff449f751f20f4aa33f1896d3f7d3bda8e47a6ce1 languageName: node linkType: hard -"react-router@npm:6.30.3": - version: 6.30.3 - resolution: "react-router@npm:6.30.3" +"react-router@npm:6.30.4": + version: 6.30.4 + resolution: "react-router@npm:6.30.4" dependencies: - "@remix-run/router": "npm:1.23.2" + "@remix-run/router": "npm:1.23.3" peerDependencies: react: ">=16.8" - checksum: 10/1a51bdcc42b8d7979228dea8b5c44a28a4add9b681781f75b74f5f920d20058a92ffe5f1d0ba0621f03abe1384b36025b53b402515ecb35f27a6a2f2f25d6fbe + checksum: 10/59c1b64a210cde2f1d069ffc47dce2d58991bd4d81bb0743e22ba7c6b742e63ebbdc976c42c3a3ebd90bf38a2f05258bd0efa82c162b259be000d39787716043 languageName: node linkType: hard @@ -33867,7 +33899,7 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.0.6, side-channel@npm:^1.1.0": +"side-channel@npm:^1.1.0": version: 1.1.0 resolution: "side-channel@npm:1.1.0" dependencies: @@ -36719,12 +36751,12 @@ __metadata: languageName: node linkType: hard -"uuid@npm:13": - version: 13.0.0 - resolution: "uuid@npm:13.0.0" +"uuid@npm:13.0.1": + version: 13.0.1 + resolution: "uuid@npm:13.0.1" bin: uuid: dist-node/bin/uuid - checksum: 10/2742b24d1e00257e60612572e4d28679423469998cafbaf1fe9f1482e3edf9c40754b31bfdb3d08d71b29239f227a304588f75210b3b48f2609f0673f1feccef + checksum: 10/da190a58eb699dfde38013c9ae8e2469eb62cc6c0774bb7d58b818a4fd1f0b269f11465d5153f88cfcf21e8b8187f35e016b2b033a53a6e06a22103ccce7a5fe languageName: node linkType: hard