From 391cf169699214f5280306d98ce5cf99d0147007 Mon Sep 17 00:00:00 2001 From: Guilherme Gazzo Date: Mon, 15 Jun 2026 16:43:37 -0300 Subject: [PATCH] feat(subscriptions): mark single thread as read via REST tmid Extend POST /v1/subscriptions.read with an optional tmid field that marks a specific thread as read (validating it belongs to the room) instead of the whole room. Add readThreadMethod to centralize the legacy readThreads behavior and migrate the thread read-marker client callers (ThreadChat, useThreadMessagesQuery) off the readThreads DDP method. --- .changeset/migrate-thread-read-callers.md | 8 +++ .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 | 15 +++--- apps/meteor/server/methods/readThreads.ts | 36 +++---------- .../tests/end-to-end/api/subscriptions.ts | 54 +++++++++++++++++++ .../src/v1/subscriptionsEndpoints.ts | 12 ++++- 9 files changed, 144 insertions(+), 43 deletions(-) create mode 100644 .changeset/migrate-thread-read-callers.md create mode 100644 .changeset/rest-subscriptions-read-tmid.md diff --git a/.changeset/migrate-thread-read-callers.md b/.changeset/migrate-thread-read-callers.md new file mode 100644 index 0000000000000..7fe5f25e1812c --- /dev/null +++ b/.changeset/migrate-thread-read-callers.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/meteor': patch +--- + +Migrate the thread read-marker client callers from the `readThreads` DDP method to `POST /v1/subscriptions.read` (now with the `tmid` field). The DDP method stays registered on the server for external SDK/mobile clients, with a deprecation log pointing at the REST route until 9.0.0 removes it. + +- `ThreadChat.tsx` +- `useThreadMessagesQuery.ts` 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/apps/meteor/app/api/server/v1/subscriptions.ts b/apps/meteor/app/api/server/v1/subscriptions.ts index 9b69aa5478d1c..1d51ee86746c3 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 { readThreadMethod } 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 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 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/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 5966aa17cad4f..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,10 +1,11 @@ import { isThreadMessage, type IMessage, type IRoom, type IThreadMainMessage, type IThreadMessage } from '@rocket.chat/core-typings'; -import { 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'; import { onClientMessageReceived } from '../../../../../lib/onClientMessageReceived'; import { roomsQueryKeys } from '../../../../../lib/queryKeys'; +import { mapMessageFromApi } from '../../../../../lib/utils/mapMessageFromApi'; import { modifyMessageOnFilesDelete } from '../../../../../lib/utils/modifyMessageOnFilesDelete'; import { createDeleteCriteria, @@ -24,7 +25,8 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR const queryClient = useQueryClient(); const queryKey = roomsQueryKeys.threadMessages(roomId, tmid); - const getThreadMessages = useMethod('getThreadMessages'); + const getThreadMessages = useEndpoint('GET', '/v1/chat.getThreadMessages'); + const markThreadRead = useEndpoint('POST', '/v1/subscriptions.read'); const subscribeToRoomMessages = useStream('room-messages'); const subscribeToNotifyRoom = useStream('notify-room'); @@ -105,10 +107,11 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR queryFn: async () => { const cachedMessages = queryClient.getQueryData(queryKey) || []; - const messages = await getThreadMessages({ tmid }); - const filtered = messages.filter( - (msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true, - ); + const { messages } = await getThreadMessages({ tmid }); + 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); const sorted = mergeThreadMessages(cachedMessages, filtered); if (unprocessedReadMessagesEvent.current) { diff --git a/apps/meteor/server/methods/readThreads.ts b/apps/meteor/server/methods/readThreads.ts index c1a6fe3807350..74c3d03293f1b 100644 --- a/apps/meteor/server/methods/readThreads.ts +++ b/apps/meteor/server/methods/readThreads.ts @@ -1,13 +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 { settings } from '../../app/settings/server'; -import { readThread } from '../../app/threads/server/functions'; -import { callbacks } from '../lib/callbacks'; +import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger'; +import { readThreadMethod } from '../../app/threads/server/functions'; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention @@ -18,33 +15,14 @@ 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')) { - 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/subscriptions.ts b/apps/meteor/tests/end-to-end/api/subscriptions.ts index 7379e59606aab..638c247aa4d84 100644 --- a/apps/meteor/tests/end-to-end/api/subscriptions.ts +++ b/apps/meteor/tests/end-to-end/api/subscriptions.ts @@ -394,6 +394,60 @@ 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 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,