Skip to content
5 changes: 5 additions & 0 deletions .changeset/fix-join-room-subscription-refresh.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/rooms-join-endpoint.md
Original file line number Diff line number Diff line change
@@ -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`.
34 changes: 33 additions & 1 deletion apps/meteor/app/api/server/v1/rooms.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -24,6 +24,7 @@ import {
isRoomsIsMemberProps,
isRoomsCleanHistoryProps,
isRoomsOpenProps,
isRoomsJoinProps,
isRoomsMembersOrderedByRoleProps,
isRoomsChangeArchivationStateProps,
isRoomsHideProps,
Expand Down Expand Up @@ -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',
{
Expand Down
2 changes: 1 addition & 1 deletion apps/meteor/app/lib/server/methods/joinRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ declare module '@rocket.chat/ddp-client' {

Meteor.methods<ServerMethods>({
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();
Expand Down
12 changes: 6 additions & 6 deletions apps/meteor/client/hooks/useJoinRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -11,20 +13,18 @@ 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) => {
await joinChannel({ roomId: rid });
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) => {
Expand Down
5 changes: 1 addition & 4 deletions apps/meteor/client/lib/chats/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,7 @@ 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> => {
// 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<IRoom | undefined> =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
148 changes: 148 additions & 0 deletions apps/meteor/tests/e2e/rooms-join.spec.ts
Original file line number Diff line number Diff line change
@@ -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'] }] });
Comment thread
dougfabris marked this conversation as resolved.
});

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<string, string>;

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<string, string>;

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();
});
});
});
Loading
Loading