diff --git a/.changeset/fix-join-room-subscription-refresh.md b/.changeset/fix-join-room-subscription-refresh.md new file mode 100644 index 0000000000000..464efd6ad7825 --- /dev/null +++ b/.changeset/fix-join-room-subscription-refresh.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixed the "not subscribed" room screen not updating after joining a room. The join mutation invalidated a stale React Query key that no longer matched the open-room query, so the UI kept showing the join prompt until a manual page refresh. It now invalidates the correct `rooms` reference key, so the room opens immediately after joining. diff --git a/.changeset/rooms-join-endpoint.md b/.changeset/rooms-join-endpoint.md new file mode 100644 index 0000000000000..3c30a35ea7ebc --- /dev/null +++ b/.changeset/rooms-join-endpoint.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/rest-typings": minor +--- + +Added a new `rooms.join` REST endpoint that lets a user join any room type, replicating the behavior of the deprecated `joinRoom` DDP method. Unlike `channels.join`, it resolves all room types through the shared `Room.join` service (access checks, join codes, federation and omnichannel rules). The client now uses `rooms.join` instead of `channels.join`. diff --git a/apps/meteor/app/api/server/v1/rooms.ts b/apps/meteor/app/api/server/v1/rooms.ts index fa68fc305af83..723c8788c7033 100644 --- a/apps/meteor/app/api/server/v1/rooms.ts +++ b/apps/meteor/app/api/server/v1/rooms.ts @@ -1,4 +1,4 @@ -import { FederationMatrix, MeteorError, Team } from '@rocket.chat/core-services'; +import { FederationMatrix, MeteorError, Room, Team } from '@rocket.chat/core-services'; import { type IRoom, type IRoomAbacRedaction, @@ -24,6 +24,7 @@ import { isRoomsIsMemberProps, isRoomsCleanHistoryProps, isRoomsOpenProps, + isRoomsJoinProps, isRoomsMembersOrderedByRoleProps, isRoomsChangeArchivationStateProps, isRoomsHideProps, @@ -1251,6 +1252,37 @@ API.v1.post( }, ); +API.v1.post( + 'rooms.join', + { + authRequired: true, + body: isRoomsJoinProps, + response: { + 200: ajv.compile<{ room: IRoom }>({ + type: 'object', + properties: { + room: { type: 'object' }, + success: { type: 'boolean', enum: [true] }, + }, + required: ['room', 'success'], + additionalProperties: false, + }), + 400: validateBadRequestErrorResponse, + 401: validateUnauthorizedErrorResponse, + }, + }, + async function action() { + const { joinCode, ...params } = this.bodyParams; + const room = await findRoomByIdOrName({ params }); + + await Room.join({ room, user: this.user, joinCode }); + + return API.v1.success({ + room: await findRoomByIdOrName({ params }), + }); + }, +); + API.v1.post( 'rooms.hide', { diff --git a/apps/meteor/app/lib/server/methods/joinRoom.ts b/apps/meteor/app/lib/server/methods/joinRoom.ts index 36c862726f8ee..3ea5d3fbe6273 100644 --- a/apps/meteor/app/lib/server/methods/joinRoom.ts +++ b/apps/meteor/app/lib/server/methods/joinRoom.ts @@ -16,7 +16,7 @@ declare module '@rocket.chat/ddp-client' { Meteor.methods({ async joinRoom(rid, code) { - methodDeprecationLogger.method('joinRoom', '9.0.0', '/v1/channels.join'); + methodDeprecationLogger.method('joinRoom', '9.0.0', '/v1/rooms.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 3685693222979..dac9745397001 100644 --- a/apps/meteor/client/hooks/useJoinRoom.ts +++ b/apps/meteor/client/hooks/useJoinRoom.ts @@ -2,6 +2,8 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { roomsQueryKeys } from '../lib/queryKeys'; + type UseJoinRoomMutationFunctionProps = { rid: IRoom['_id']; reference: string; @@ -11,11 +13,7 @@ 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'); + const joinChannel = useEndpoint('POST', '/v1/rooms.join'); return useMutation({ mutationFn: async ({ rid, reference, type }: UseJoinRoomMutationFunctionProps) => { @@ -23,8 +21,10 @@ export const useJoinRoom = () => { return { reference, type }; }, onSuccess: (data) => { + // Prefix-match the open-room query key (roomsQueryKeys.roomReference) so the + // "not subscribed" screen refetches and flips to the joined state without a reload. queryClient.invalidateQueries({ - queryKey: ['rooms', data], + queryKey: [...roomsQueryKeys.all, data.reference, data.type], }); }, onError: (error: unknown) => { diff --git a/apps/meteor/client/lib/chats/data.ts b/apps/meteor/client/lib/chats/data.ts index 75f4d457b36af..484e665ec6241 100644 --- a/apps/meteor/client/lib/chats/data.ts +++ b/apps/meteor/client/lib/chats/data.ts @@ -274,10 +274,7 @@ 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 => { - // 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 }); + await sdk.rest.post('/v1/rooms.join', { roomId: rid }); }; const findDiscussionByID = async (drid: IRoom['_id']): Promise => diff --git a/apps/meteor/client/views/room/composer/ComposerJoinWithPassword.tsx b/apps/meteor/client/views/room/composer/ComposerJoinWithPassword.tsx index 2157192d9533f..417bfe811f37b 100644 --- a/apps/meteor/client/views/room/composer/ComposerJoinWithPassword.tsx +++ b/apps/meteor/client/views/room/composer/ComposerJoinWithPassword.tsx @@ -10,7 +10,7 @@ const ComposerJoinWithPassword = () => { const room = useRoom(); const dispatchToastMessage = useToastMessageDispatch(); - const joinChannelEndpoint = useEndpoint('POST', '/v1/channels.join'); + const joinChannelEndpoint = useEndpoint('POST', '/v1/rooms.join'); const { control, handleSubmit, diff --git a/apps/meteor/client/views/room/composer/ComposerReadOnly.tsx b/apps/meteor/client/views/room/composer/ComposerReadOnly.tsx index 16071984db086..0e122a15f350b 100644 --- a/apps/meteor/client/views/room/composer/ComposerReadOnly.tsx +++ b/apps/meteor/client/views/room/composer/ComposerReadOnly.tsx @@ -10,7 +10,7 @@ const ComposerReadOnly = () => { const { t } = useTranslation(); const room = useRoom(); const isSubscribed = useUserIsSubscribed(); - const joinChannel = useEndpoint('POST', '/v1/channels.join'); + const joinChannel = useEndpoint('POST', '/v1/rooms.join'); const dispatchToastMessage = useToastMessageDispatch(); diff --git a/apps/meteor/tests/e2e/rooms-join.spec.ts b/apps/meteor/tests/e2e/rooms-join.spec.ts new file mode 100644 index 0000000000000..28bbdb8929069 --- /dev/null +++ b/apps/meteor/tests/e2e/rooms-join.spec.ts @@ -0,0 +1,148 @@ +import { faker } from '@faker-js/faker'; +import type { IRoom } from '@rocket.chat/core-typings'; + +import { Users } from './fixtures/userStates'; +import { HomeChannel } from './page-objects'; +import { + createTargetChannel, + createTargetDiscussion, + createTargetGroupAndReturnFullRoom, + deleteRoom, + sendTargetChannelMessage, +} from './utils'; +import { test, expect } from './utils/test'; + +test.describe.serial('Join rooms', () => { + test.use({ storageState: Users.user1.state }); + + test.describe('public channels without preview-c-room', () => { + let targetChannel: string; + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ api }) => { + targetChannel = await createTargetChannel(api); + await sendTargetChannelMessage(api, targetChannel, { msg: 'message from a channel the user has not joined' }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await page.goto(`/channel/${targetChannel}`); + }); + + test.afterEach(async ({ api }) => { + await api.post('/channels.delete', { roomName: targetChannel }); + }); + + test.beforeAll(async ({ api }) => { + // restrict preview to admin so a regular user lands on the "not subscribed" + // screen, whose "Join channel" button drives /v1/rooms.join via useJoinRoom + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin'] }] }); + }); + + test.afterAll(async ({ api }) => { + await api.post('/permissions.update', { permissions: [{ _id: 'preview-c-room', roles: ['admin', 'user', 'anonymous'] }] }); + }); + + test('should let a non-member join a public channel', async () => { + await expect(poHomeChannel.btnJoinChannel).toBeVisible(); + await poHomeChannel.btnJoinChannel.click(); + await expect(poHomeChannel.btnJoinChannel).not.toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + }); + }); + + test.describe('public channel with preview-c-room', () => { + let targetChannel: string; + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + }); + + test.beforeEach(async ({ api }) => { + targetChannel = await createTargetChannel(api); + await sendTargetChannelMessage(api, targetChannel, { msg: 'message from a channel the user has not joined' }); + }); + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + await page.goto(`/channel/${targetChannel}`); + }); + + test.afterEach(async ({ api }) => { + await api.post('/channels.delete', { roomName: targetChannel }); + }); + + test('should let a non-member join a public channel', async () => { + await expect(poHomeChannel.composer.btnJoinRoom).toBeVisible(); + await poHomeChannel.composer.btnJoinRoom.click(); + await expect(poHomeChannel.composer.btnJoinRoom).not.toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + }); + }); + + test.describe('discussion with preview-c-room', () => { + test.use({ storageState: Users.user1.state }); + + let poHomeChannel: HomeChannel; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + }); + + let discussion: Record; + + test.beforeAll(async ({ api }) => { + discussion = await createTargetDiscussion(api); + }); + + test.afterAll(async ({ api }) => { + await deleteRoom(api, discussion._id); + }); + + test('should let a non-member join a discussion', async ({ page }) => { + await page.goto(`/channel/${discussion.name}`); + + await expect(poHomeChannel.composer.btnJoinRoom).toBeVisible(); + + await poHomeChannel.composer.btnJoinRoom.click(); + + await expect(poHomeChannel.composer.btnJoinRoom).not.toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + }); + }); + + test.describe('discussion inside a private channel', () => { + let poHomeChannel: HomeChannel; + let group: IRoom; + let discussion: Record; + + test.beforeEach(async ({ page }) => { + poHomeChannel = new HomeChannel(page); + }); + + test.beforeAll(async ({ api }) => { + // The user is a member of the private parent but NOT of the discussion. + // The discussion is type `p` and its access is inherited from the parent, + // so `channels.join` could not resolve it but `rooms.join` can. + ({ group } = await createTargetGroupAndReturnFullRoom(api, { members: [Users.user1.data.username] })); + const response = await api.post('/rooms.createDiscussion', { prid: group._id, t_name: faker.string.uuid() }); + ({ discussion } = await response.json()); + }); + + test.afterAll(async ({ api }) => { + await deleteRoom(api, discussion._id); + await api.post('/groups.delete', { roomId: group._id }); + }); + + test('should let a parent member join a discussion in a private channel', async ({ page }) => { + await page.goto(`/group/${discussion.name}`); + + await expect(poHomeChannel.composer.btnJoinRoom).toBeVisible(); + await poHomeChannel.composer.btnJoinRoom.click(); + + await expect(poHomeChannel.composer.btnJoinRoom).not.toBeVisible(); + await expect(poHomeChannel.composer.inputMessage).toBeEnabled(); + }); + }); +}); diff --git a/apps/meteor/tests/end-to-end/api/rooms.ts b/apps/meteor/tests/end-to-end/api/rooms.ts index 350c31c190454..13f5221a56114 100644 --- a/apps/meteor/tests/end-to-end/api/rooms.ts +++ b/apps/meteor/tests/end-to-end/api/rooms.ts @@ -1966,6 +1966,159 @@ describe('[Rooms]', () => { }); }); + describe('[/rooms.join]', () => { + let testChannel: IRoom; + let testGroup: IRoom; + let testChannelWithCode: IRoom; + let testDiscussion: IRoom; + let testUser: TestUser; + let testUserCredentials: Credentials; + + before(async () => { + testUser = await createUser(); + testUserCredentials = await login(testUser.username, password); + testChannel = (await createRoom({ type: 'c', name: `rooms.join.channel.${Date.now()}` })).body.channel; + testGroup = (await createRoom({ type: 'p', name: `rooms.join.group.${Date.now()}` })).body.group; + testChannelWithCode = (await createRoom({ type: 'c', name: `rooms.join.code.${Date.now()}` })).body.channel; + testDiscussion = ( + await request + .post(api('rooms.createDiscussion')) + .set(credentials) + .send({ prid: testChannel._id, t_name: `rooms.join.discussion.${Date.now()}` }) + ).body.discussion; + }); + + after(() => + Promise.all([ + deleteRoom({ type: 'c', roomId: testChannel._id }), + deleteRoom({ type: 'p', roomId: testGroup._id }), + deleteRoom({ type: 'c', roomId: testChannelWithCode._id }), + deleteUser(testUser), + updatePermission('join-without-join-code', ['admin', 'bot', 'app']), + ]), + ); + + it('should fail when the room does not exist', (done) => { + void request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: 'invalid-room-id' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-room-not-found'); + }) + .end(done); + }); + + it('should join a public channel by roomId', (done) => { + void request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testChannel._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('room._id', testChannel._id); + }) + .end(done); + }); + + it('should join a public channel by roomName', (done) => { + void request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomName: testChannel.name }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('room._id', testChannel._id); + }) + .end(done); + }); + + it('should join a discussion (a room with a parent room) by roomId', (done) => { + void request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testDiscussion._id }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('room._id', testDiscussion._id); + expect(res.body).to.have.nested.property('room.prid', testChannel._id); + }) + .end(done); + }); + + it('should fail to join a private group the user cannot access', (done) => { + void request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testGroup._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-not-allowed'); + }) + .end(done); + }); + + describe('with a join code', () => { + before(async () => { + await request.post(api('channels.setJoinCode')).set(credentials).send({ roomId: testChannelWithCode._id, joinCode: '123' }); + await updatePermission('join-without-join-code', []); + }); + + it('should fail to join without a join code', (done) => { + void request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testChannelWithCode._id }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-code-required'); + }) + .end(done); + }); + + it('should fail to join with an incorrect join code', (done) => { + void request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testChannelWithCode._id, joinCode: 'WRONG' }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-code-invalid'); + }) + .end(done); + }); + + it('should join with the correct join code', (done) => { + void request + .post(api('rooms.join')) + .set(testUserCredentials) + .send({ roomId: testChannelWithCode._id, joinCode: '123' }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.nested.property('room._id', testChannelWithCode._id); + }) + .end(done); + }); + }); + }); + describe('[/rooms.autocomplete.channelAndPrivate]', () => { let testChannel: IRoom; diff --git a/packages/rest-typings/src/v1/rooms.ts b/packages/rest-typings/src/v1/rooms.ts index d433992c15735..b91a847898140 100644 --- a/packages/rest-typings/src/v1/rooms.ts +++ b/packages/rest-typings/src/v1/rooms.ts @@ -764,6 +764,45 @@ const roomsOpenSchema = { export const isRoomsOpenProps = ajv.compile(roomsOpenSchema); +export type RoomsJoinProps = { roomId: string; joinCode?: string } | { roomName: string; joinCode?: string }; + +const roomsJoinSchema = { + oneOf: [ + { + type: 'object', + properties: { + roomId: { + type: 'string', + minLength: 1, + }, + joinCode: { + type: 'string', + nullable: true, + }, + }, + required: ['roomId'], + additionalProperties: false, + }, + { + type: 'object', + properties: { + roomName: { + type: 'string', + minLength: 1, + }, + joinCode: { + type: 'string', + nullable: true, + }, + }, + required: ['roomName'], + additionalProperties: false, + }, + ], +}; + +export const isRoomsJoinProps = ajv.compile(roomsJoinSchema); + type MembersOrderedByRoleProps = { roomId?: IRoom['_id']; roomName?: IRoom['name']; @@ -1000,6 +1039,12 @@ export type RoomsEndpoints = { POST: (params: RoomsOpenProps) => void; }; + '/v1/rooms.join': { + POST: (params: RoomsJoinProps) => { + room: IRoom; + }; + }; + '/v1/rooms.membersOrderedByRole': { GET: (params: RoomsMembersOrderedByRoleProps) => PaginatedResult<{ members: (IUser & { subscription: Pick })[];