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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/ddp-migrate-batch2-callers.md
Original file line number Diff line number Diff line change
@@ -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`
6 changes: 6 additions & 0 deletions .changeset/rest-chat-delete-by-fileid.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions .changeset/rest-spotlight-params-and-anonymous.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/rest-users-setpreferences-utcoffset.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/swift-rooms-load.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 14 additions & 5 deletions apps/meteor/app/api/server/v1/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,7 @@ const chatEndpoints = API.v1
authRequired: true,
body: isChatDeleteProps,
response: {
200: ajv.compile<{ _id: string; ts: string; message: Pick<IMessage, '_id' | 'rid' | 'u'> }>({
200: ajv.compile<{ _id?: string; ts?: string; message?: Pick<IMessage, '_id' | 'rid' | 'u'> }>({
type: 'object',
properties: {
_id: { type: 'string' },
Expand All @@ -538,21 +538,30 @@ const chatEndpoints = API.v1
},
success: { type: 'boolean', enum: [true] },
},
required: ['_id', 'ts', 'message', 'success'],
required: ['success'],
additionalProperties: false,
}),
400: validateBadRequestErrorResponse,
401: validateUnauthorizedErrorResponse,
},
},
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.');
}

Expand All @@ -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 },
});
},
)
Expand Down
25 changes: 20 additions & 5 deletions apps/meteor/app/api/server/v1/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
ajv,
isShieldSvgProps,
isSpotlightProps,
parseSpotlightUsernames,
parseSpotlightType,
isDirectoryProps,
isFingerprintProps,
isMeteorCall,
Expand Down Expand Up @@ -339,7 +341,11 @@ API.v1.get(
);

const spotlightResponseSchema = ajv.compile<{
users: Pick<IUser, 'name' | 'status' | 'statusText' | 'avatarETag' | '_id' | 'username'>[];
users: (Pick<IUser, 'name' | '_id' | 'username'> &
Partial<Pick<IUser, 'status' | 'statusText' | 'avatarETag'>> & {
nickname?: string;
outside?: boolean;
})[];
rooms: Pick<IRoom, 't' | 'name' | 'lastMessage' | '_id'>[];
}>({
type: 'object',
Expand All @@ -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,
},
},
Expand All @@ -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,
Expand All @@ -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);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -12,6 +14,7 @@ declare module '@rocket.chat/ddp-client' {

Meteor.methods<ServerMethods>({
async listCustomSounds() {
methodDeprecationLogger.method('listCustomSounds', '9.0.0', '/v1/custom-sounds.list');
return CustomSounds.find({}).toArray();
},
});
50 changes: 24 additions & 26 deletions apps/meteor/app/lib/server/functions/loadMessageHistory.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
Expand All @@ -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');
Expand All @@ -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,
};
}
3 changes: 3 additions & 0 deletions apps/meteor/app/lib/server/methods/joinRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -14,6 +16,7 @@ declare module '@rocket.chat/ddp-client' {

Meteor.methods<ServerMethods>({
async joinRoom(rid, code) {
methodDeprecationLogger.method('joinRoom', '9.0.0', '/v1/channels.join');
check(rid, String);

const user = await Meteor.userAsync();
Expand Down
18 changes: 9 additions & 9 deletions apps/meteor/app/ui-utils/client/lib/RoomHistoryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<HTMLElement>('.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'),
Expand All @@ -193,15 +193,15 @@ 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;

if (messages.length < limit) {
this.updateRoom(rid, { hasMore: false });
}

if (room.hasMore && (visibleMessages.length === 0 || room.loaded < limit)) {
if (room.hasMore && visibleMessages.length === 0) {
return this.getMore(rid);
}

Expand Down
12 changes: 7 additions & 5 deletions apps/meteor/client/hooks/useJoinRoom.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) => {
Expand Down
5 changes: 4 additions & 1 deletion apps/meteor/client/lib/chats/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,10 @@ export const createDataAPI = ({ rid, tmid }: { rid: IRoom['_id']; tmid: IMessage
const isSubscribedToRoom = async (): Promise<boolean> => !!Subscriptions.state.find((record) => record.rid === rid);

const joinRoom = async (): Promise<void> => {
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<IRoom | undefined> =>
Expand Down
Loading
Loading