Skip to content

Commit d1cad08

Browse files
committed
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.
1 parent 3a72f99 commit d1cad08

9 files changed

Lines changed: 138 additions & 43 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@rocket.chat/meteor': patch
3+
---
4+
5+
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.
6+
7+
- `ThreadChat.tsx`
8+
- `useThreadMessagesQuery.ts`
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rocket.chat/rest-typings': minor
3+
'@rocket.chat/meteor': minor
4+
---
5+
6+
`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.

apps/meteor/app/api/server/v1/subscriptions.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ISubscription } from '@rocket.chat/core-typings';
2-
import { Rooms, Subscriptions } from '@rocket.chat/models';
2+
import { Messages, Rooms, Subscriptions } from '@rocket.chat/models';
33
import {
44
ajv,
55
isSubscriptionsGetProps,
@@ -14,6 +14,7 @@ import { Meteor } from 'meteor/meteor';
1414
import { readMessages } from '../../../../server/lib/readMessages';
1515
import { getSubscriptions } from '../../../../server/publications/subscription';
1616
import { unreadMessages } from '../../../message-mark-as-unread/server/unreadMessages';
17+
import { readThreadMethod } from '../../../threads/server/functions';
1718
import { API } from '../api';
1819

1920
const subscriptionsGetResponseSchema = ajv.compile<{
@@ -139,14 +140,23 @@ API.v1.post(
139140
},
140141
},
141142
async function action() {
142-
const { readThreads = false } = this.bodyParams;
143+
const { readThreads = false, tmid } = this.bodyParams;
143144
const roomId = 'rid' in this.bodyParams ? this.bodyParams.rid : this.bodyParams.roomId;
144145

145146
const room = await Rooms.findOneById(roomId);
146147
if (!room) {
147148
throw new Error('error-invalid-subscription');
148149
}
149150

151+
if (tmid) {
152+
const thread = await Messages.findOneById(tmid);
153+
if (thread?.rid !== room._id) {
154+
throw new Error('error-invalid-thread');
155+
}
156+
await readThreadMethod({ user: this.user, tmid });
157+
return API.v1.success();
158+
}
159+
150160
await readMessages(room, this.userId, readThreads);
151161

152162
return API.v1.success();

apps/meteor/app/threads/server/functions.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import type { IMessage, IRoom, IUser } from '@rocket.chat/core-typings';
22
import { isEditedMessage } from '@rocket.chat/core-typings';
3-
import { Messages, Subscriptions, NotificationQueue } from '@rocket.chat/models';
3+
import { Messages, Rooms, Subscriptions, NotificationQueue } from '@rocket.chat/models';
4+
import { Meteor } from 'meteor/meteor';
45

56
import { callbacks } from '../../../server/lib/callbacks';
7+
import { canAccessRoomAsync } from '../../authorization/server';
68
import {
79
notifyOnSubscriptionChangedByRoomIdAndUserIds,
810
notifyOnSubscriptionChangedByRoomIdAndUserId,
911
} from '../../lib/server/lib/notifyListener';
1012
import { getMentions, getUserIdsFromHighlights } from '../../lib/server/lib/notifyUsersOnMessage';
13+
import { settings } from '../../settings/server';
1114

1215
export async function reply({ tmid }: { tmid?: string }, message: IMessage, parentMessage: IMessage, followers: string[]) {
1316
if (!tmid || isEditedMessage(message)) {
@@ -98,3 +101,32 @@ export const readThread = async ({ user, room, tmid }: { user: IUser; room: IRoo
98101

99102
callbacks.runAsync('afterReadMessages', room, { uid: user._id, tmid });
100103
};
104+
105+
/**
106+
* Marks a thread as read for a user, replicating the full behavior of the
107+
* legacy `readThreads` DDP method: it validates that threads are enabled, that
108+
* the thread and its room exist, that the user can access the room, and runs
109+
* the `beforeReadMessages` callback before clearing the thread unread state.
110+
*/
111+
export const readThreadMethod = async ({ user, tmid }: { user: IUser; tmid: IMessage['_id'] }) => {
112+
if (!settings.get('Threads_enabled')) {
113+
throw new Meteor.Error('error-not-allowed', 'Threads Disabled', { method: 'readThreads' });
114+
}
115+
116+
const thread = await Messages.findOneById(tmid);
117+
if (!thread) {
118+
return;
119+
}
120+
121+
const room = await Rooms.findOneById(thread.rid);
122+
if (!room) {
123+
throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'readThreads' });
124+
}
125+
126+
if (!(await canAccessRoomAsync(room, user))) {
127+
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'readThreads' });
128+
}
129+
130+
await callbacks.run('beforeReadMessages', thread.rid, user._id);
131+
await readThread({ user, room, tmid });
132+
};

apps/meteor/client/views/room/contextualBar/Threads/components/ThreadChat.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { IMessage, IThreadMainMessage } from '@rocket.chat/core-typings';
22
import { isEditedMessage } from '@rocket.chat/core-typings';
33
import { Box, CheckBox, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
44
import { clientCallbacks, ContextualbarContent } from '@rocket.chat/ui-client';
5-
import { useMethod, useTranslation, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts';
5+
import { useEndpoint, useTranslation, useUserPreference, useRoomToolbox } from '@rocket.chat/ui-contexts';
66
import { useState, useEffect, useCallback, useId } from 'react';
77

88
import ThreadMessageList from './ThreadMessageList';
@@ -62,7 +62,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => {
6262
}, [chat?.messageEditing]);
6363

6464
const room = useRoom();
65-
const readThreads = useMethod('readThreads');
65+
const markThreadRead = useEndpoint('POST', '/v1/subscriptions.read');
6666
useEffect(() => {
6767
clientCallbacks.add(
6868
'streamNewMessage',
@@ -71,7 +71,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => {
7171
return;
7272
}
7373

74-
readThreads(mainMessage._id);
74+
void markThreadRead({ rid: room._id, tmid: mainMessage._id });
7575
},
7676
clientCallbacks.priority.MEDIUM,
7777
`thread-${room._id}`,
@@ -80,7 +80,7 @@ const ThreadChat = ({ mainMessage }: ThreadChatProps) => {
8080
return () => {
8181
clientCallbacks.remove('streamNewMessage', `thread-${room._id}`);
8282
};
83-
}, [mainMessage._id, readThreads, room._id]);
83+
}, [mainMessage._id, markThreadRead, room._id]);
8484

8585
const subscription = useRoomSubscription();
8686
const sendToChannelID = useId();

apps/meteor/client/views/room/contextualBar/Threads/hooks/useThreadMessagesQuery.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { isThreadMessage, type IMessage, type IRoom, type IThreadMainMessage, type IThreadMessage } from '@rocket.chat/core-typings';
2-
import { useEndpoint, useMethod, useStream } from '@rocket.chat/ui-contexts';
2+
import { useEndpoint, useStream } from '@rocket.chat/ui-contexts';
33
import { useQuery, useQueryClient } from '@tanstack/react-query';
44
import { useEffect, useRef } from 'react';
55

@@ -26,10 +26,7 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR
2626
const queryClient = useQueryClient();
2727
const queryKey = roomsQueryKeys.threadMessages(roomId, tmid);
2828
const getThreadMessages = useEndpoint('GET', '/v1/chat.getThreadMessages');
29-
// REST has no per-thread read-marker endpoint yet; fall back to the
30-
// `readThreads` DDP method so the side effect that DDP getThreadMessages
31-
// used to do server-side keeps happening for callers.
32-
const readThreads = useMethod('readThreads');
29+
const markThreadRead = useEndpoint('POST', '/v1/subscriptions.read');
3330

3431
const subscribeToRoomMessages = useStream('room-messages');
3532
const subscribeToNotifyRoom = useStream('notify-room');
@@ -111,7 +108,7 @@ export const useThreadMessagesQuery = (tmid: IThreadMainMessage['_id'], rid?: IR
111108
const cachedMessages = queryClient.getQueryData<IThreadMessage[]>(queryKey) || [];
112109

113110
const { messages } = await getThreadMessages({ tmid });
114-
void Promise.resolve(readThreads(tmid)).catch(() => undefined);
111+
void markThreadRead({ rid: roomId, tmid }).catch(() => undefined);
115112
const filtered = messages
116113
.map((m) => mapMessageFromApi(m))
117114
.filter((msg): msg is IThreadMessage => isThreadMessage(msg) && msg.tmid === tmid && msg._id !== tmid && msg._hidden !== true);
Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
import type { IMessage, IUser } from '@rocket.chat/core-typings';
22
import type { ServerMethods } from '@rocket.chat/ddp-client';
3-
import { Messages, Rooms } from '@rocket.chat/models';
43
import { check } from 'meteor/check';
54
import { Meteor } from 'meteor/meteor';
65

7-
import { canAccessRoomAsync } from '../../app/authorization/server';
8-
import { settings } from '../../app/settings/server';
9-
import { readThread } from '../../app/threads/server/functions';
10-
import { callbacks } from '../lib/callbacks';
6+
import { methodDeprecationLogger } from '../../app/lib/server/lib/deprecationWarningLogger';
7+
import { readThreadMethod } from '../../app/threads/server/functions';
118

129
declare module '@rocket.chat/ddp-client' {
1310
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -18,33 +15,14 @@ declare module '@rocket.chat/ddp-client' {
1815

1916
Meteor.methods<ServerMethods>({
2017
async readThreads(tmid) {
18+
methodDeprecationLogger.method('readThreads', '9.0.0', '/v1/subscriptions.read');
2119
check(tmid, String);
2220

23-
if (!Meteor.userId() || !settings.get('Threads_enabled')) {
24-
throw new Meteor.Error('error-not-allowed', 'Threads Disabled', {
25-
method: 'getThreadMessages',
26-
});
21+
const user = (await Meteor.userAsync()) as IUser | null;
22+
if (!user) {
23+
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'readThreads' });
2724
}
2825

29-
const thread = await Messages.findOneById(tmid);
30-
if (!thread) {
31-
return;
32-
}
33-
34-
const user = (await Meteor.userAsync()) ?? undefined;
35-
36-
const room = await Rooms.findOneById(thread.rid);
37-
if (!room) {
38-
throw new Meteor.Error('error-room-does-not-exist', 'This room does not exist', { method: 'getThreadMessages' });
39-
}
40-
41-
if (!(await canAccessRoomAsync(room, user))) {
42-
throw new Meteor.Error('error-not-allowed', 'Not allowed', { method: 'getThreadMessages' });
43-
}
44-
45-
await callbacks.run('beforeReadMessages', thread.rid, user?._id);
46-
if (user?._id) {
47-
await readThread({ user: user as IUser, room, tmid });
48-
}
26+
await readThreadMethod({ user, tmid });
4927
},
5028
});

apps/meteor/tests/end-to-end/api/subscriptions.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,60 @@ describe('[Subscriptions]', () => {
394394
expect(res.body.subscription.tunread).to.deep.equal([threadId]);
395395
});
396396
});
397+
398+
it('should mark a single thread as read when a tmid is provided', async () => {
399+
await request
400+
.post(api('chat.sendMessage'))
401+
.set(threadUserCredentials)
402+
.send({
403+
message: {
404+
rid: testChannel._id,
405+
msg: `@${adminUsername} making admin follow this thread`,
406+
tmid: threadId,
407+
},
408+
});
409+
410+
await request
411+
.post(api('subscriptions.read'))
412+
.set(credentials)
413+
.send({
414+
rid: testChannel._id,
415+
tmid: threadId,
416+
})
417+
.expect(200)
418+
.expect((res) => {
419+
expect(res.body).to.have.property('success', true);
420+
});
421+
422+
await request
423+
.get(api('subscriptions.getOne'))
424+
.set(credentials)
425+
.query({
426+
roomId: testChannel._id,
427+
})
428+
.expect(200)
429+
.expect((res) => {
430+
expect(res.body).to.have.property('success', true);
431+
// removeUnreadThreadByRoomIdAndUserId only $pulls the tmid, leaving an empty array
432+
expect(res.body.subscription).to.have.property('tunread').that.is.an('array').and.does.not.include(threadId);
433+
});
434+
});
435+
436+
it('should fail when the tmid does not belong to the provided room', (done) => {
437+
void request
438+
.post(api('subscriptions.read'))
439+
.set(credentials)
440+
.send({
441+
rid: testGroup._id,
442+
tmid: threadId,
443+
})
444+
.expect(400)
445+
.expect((res) => {
446+
expect(res.body).to.have.property('success', false);
447+
expect(res.body).to.have.property('error');
448+
})
449+
.end(done);
450+
});
397451
});
398452
});
399453

packages/rest-typings/src/v1/subscriptionsEndpoints.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ type SubscriptionsGet = { updatedSince?: string };
66

77
type SubscriptionsGetOne = { roomId: IRoom['_id'] };
88

9-
type SubscriptionsRead = { rid: IRoom['_id']; readThreads?: boolean } | { roomId: IRoom['_id']; readThreads?: boolean };
9+
type SubscriptionsRead =
10+
| { rid: IRoom['_id']; readThreads?: boolean; tmid?: IMessage['_id'] }
11+
| { roomId: IRoom['_id']; readThreads?: boolean; tmid?: IMessage['_id'] };
1012

1113
type SubscriptionsUnread = { roomId: IRoom['_id'] } | { firstUnreadMessage: Pick<IMessage, '_id'> };
1214

@@ -49,6 +51,10 @@ const SubscriptionsReadSchema = {
4951
type: 'boolean',
5052
nullable: true,
5153
},
54+
tmid: {
55+
type: 'string',
56+
nullable: true,
57+
},
5258
},
5359
required: ['rid'],
5460
additionalProperties: false,
@@ -63,6 +69,10 @@ const SubscriptionsReadSchema = {
6369
type: 'boolean',
6470
nullable: true,
6571
},
72+
tmid: {
73+
type: 'string',
74+
nullable: true,
75+
},
6676
},
6777
required: ['roomId'],
6878
additionalProperties: false,

0 commit comments

Comments
 (0)