Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'] }] });
});

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