-
Notifications
You must be signed in to change notification settings - Fork 13.7k
feat(subscriptions): mark a single thread as read via REST tmid #40957
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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` |
| 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/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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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,14 +140,23 @@ 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); | ||||||||||||||||||||||||||||||||||
| if (!room) { | ||||||||||||||||||||||||||||||||||
| 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(); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+151
to
+158
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Branch on presence, not truthiness, for With Proposed fix- if (tmid) {
+ if (tmid !== undefined && tmid !== null) {
const thread = await Messages.findOneById(tmid);
if (thread?.rid !== room._id) {
throw new Error('error-invalid-thread');📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| await readMessages(room, this.userId, readThreads); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| return API.v1.success(); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 }); | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Handle the dropped endpoint rejection. This fire-and-forget call can reject, and without a Suggested change- void markThreadRead({ rid: room._id, tmid: mainMessage._id });
+ void markThreadRead({ rid: room._id, tmid: mainMessage._id }).catch(() => undefined);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This fire-and-forget call lacks a Prompt for AI agents
Suggested change
|
||||||||||
| }, | ||||||||||
| 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(); | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<IThreadMessage[]>(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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Mutation side-effect inside queryFn: Prompt for AI agents |
||
| 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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) => { | ||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Error-assertion test only checks Prompt for AI agents |
||||||||||||||||||||
| 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); | ||||||||||||||||||||
|
Comment on lines
+429
to
+432
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Assert the API contract, not the empty-array artifact. This test will fail on a valid implementation that removes Suggested change- // 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);
+ const tunread = res.body.subscription.tunread ?? [];
+ expect(tunread).to.be.an('array');
+ expect(tunread).to.not.include(threadId);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| }); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| 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); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Use a presence/type check for
tmidinstead of truthiness. An empty stringtmidis currently treated as omitted, so a malformed thread-read request can mark the entire room read.Prompt for AI agents