From e41b3af7a9e052ad656d9f2bf501b739ec1bd43a Mon Sep 17 00:00:00 2001 From: Tasso Evangelista Date: Fri, 13 Mar 2026 09:57:28 -0300 Subject: [PATCH 1/2] refactor(ui-client): Move `useThemeMode` (#39348) --- .../sidebar/footer/SidebarFooterDefault.tsx | 2 +- .../meteor/client/views/root/AppErrorPage.tsx | 2 +- .../MainLayout/LayoutWithSidebar.spec.tsx | 1 + .../root/MainLayout/MainLayoutStyleTags.tsx | 2 +- apps/meteor/package.json | 1 - ee/packages/ui-theming/CHANGELOG.md | 149 ------------------ ee/packages/ui-theming/package.json | 44 ------ ee/packages/ui-theming/src/index.ts | 1 - ee/packages/ui-theming/tsconfig-build.json | 4 - ee/packages/ui-theming/tsconfig.json | 8 - packages/ui-client/src/hooks/index.ts | 3 +- .../ui-client/src/hooks/useThemeMode.spec.ts | 133 ++++++++++++++++ .../ui-client}/src/hooks/useThemeMode.ts | 6 +- yarn.lock | 32 +--- 14 files changed, 145 insertions(+), 243 deletions(-) delete mode 100644 ee/packages/ui-theming/CHANGELOG.md delete mode 100644 ee/packages/ui-theming/package.json delete mode 100644 ee/packages/ui-theming/src/index.ts delete mode 100644 ee/packages/ui-theming/tsconfig-build.json delete mode 100644 ee/packages/ui-theming/tsconfig.json create mode 100644 packages/ui-client/src/hooks/useThemeMode.spec.ts rename {ee/packages/ui-theming => packages/ui-client}/src/hooks/useThemeMode.ts (91%) diff --git a/apps/meteor/client/sidebar/footer/SidebarFooterDefault.tsx b/apps/meteor/client/sidebar/footer/SidebarFooterDefault.tsx index 291799c361d36..3950f5ca09e7f 100644 --- a/apps/meteor/client/sidebar/footer/SidebarFooterDefault.tsx +++ b/apps/meteor/client/sidebar/footer/SidebarFooterDefault.tsx @@ -1,7 +1,7 @@ import { css } from '@rocket.chat/css-in-js'; import { Box, SidebarDivider, Palette, SidebarFooter as Footer } from '@rocket.chat/fuselage'; +import { useThemeMode } from '@rocket.chat/ui-client'; import { useSetting } from '@rocket.chat/ui-contexts'; -import { useThemeMode } from '@rocket.chat/ui-theming'; import DOMPurify from 'dompurify'; import type { ReactElement } from 'react'; diff --git a/apps/meteor/client/views/root/AppErrorPage.tsx b/apps/meteor/client/views/root/AppErrorPage.tsx index 2d6dd2860c48e..c71697882ae1c 100644 --- a/apps/meteor/client/views/root/AppErrorPage.tsx +++ b/apps/meteor/client/views/root/AppErrorPage.tsx @@ -1,5 +1,5 @@ import { Box, PaletteStyleTag, States, StatesAction, StatesActions, StatesIcon, StatesSubtitle, StatesTitle } from '@rocket.chat/fuselage'; -import { useThemeMode } from '@rocket.chat/ui-theming'; +import { useThemeMode } from '@rocket.chat/ui-client'; import type { ErrorInfo, ReactElement } from 'react'; type AppErrorPageProps = { diff --git a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.spec.tsx b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.spec.tsx index 90e7d2c1f8ba7..f87e63732ae51 100644 --- a/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.spec.tsx +++ b/apps/meteor/client/views/root/MainLayout/LayoutWithSidebar.spec.tsx @@ -21,6 +21,7 @@ jest.mock('../../navigation/providers/RoomsNavigationProvider', () => ({ })); jest.mock('@rocket.chat/ui-client', () => ({ + ...jest.requireActual('@rocket.chat/ui-client'), FeaturePreview: ({ children }: { children: ReactNode }) => <>{children}, FeaturePreviewOn: ({ children }: { children: ReactNode }) => <>{children}, FeaturePreviewOff: ({ children }: { children: ReactNode }) => <>{children}, diff --git a/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx b/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx index 4e891bf2d3a91..4b36a7502302d 100644 --- a/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx +++ b/apps/meteor/client/views/root/MainLayout/MainLayoutStyleTags.tsx @@ -1,5 +1,5 @@ import { PaletteStyleTag } from '@rocket.chat/fuselage'; -import { useThemeMode } from '@rocket.chat/ui-theming'; +import { useThemeMode } from '@rocket.chat/ui-client'; import { codeBlock } from '../lib/codeBlockStyles'; diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 2ea7d85d76bec..e0181a8d66ca1 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -154,7 +154,6 @@ "@rocket.chat/ui-composer": "workspace:^", "@rocket.chat/ui-contexts": "workspace:^", "@rocket.chat/ui-kit": "workspace:~", - "@rocket.chat/ui-theming": "workspace:^", "@rocket.chat/ui-video-conf": "workspace:^", "@rocket.chat/ui-voip": "workspace:^", "@rocket.chat/web-ui-registration": "workspace:^", diff --git a/ee/packages/ui-theming/CHANGELOG.md b/ee/packages/ui-theming/CHANGELOG.md deleted file mode 100644 index 31dc7f1db1de4..0000000000000 --- a/ee/packages/ui-theming/CHANGELOG.md +++ /dev/null @@ -1,149 +0,0 @@ -# @rocket.chat/ui-theming - -## 0.4.4 - -### Patch Changes - -- ([#36207](https://github.com/RocketChat/Rocket.Chat/pull/36207)) Introduces the Outbound Message feature to Omnichannel, allowing organizations to initiate proactive communication with contacts through their preferred messaging channel directly from Rocket.Chat - -## 0.4.4-rc.0 - -### Patch Changes - -- ([#36207](https://github.com/RocketChat/Rocket.Chat/pull/36207)) Introduces the Outbound Message feature to Omnichannel, allowing organizations to initiate proactive communication with contacts through their preferred messaging channel directly from Rocket.Chat - -## 0.4.3 - -### Patch Changes - -- ([#35286](https://github.com/RocketChat/Rocket.Chat/pull/35286)) Bumps fuselage and related packages versions to use the most recent releases of each package, especially the fix for the missing track of the fuselage slider component - -## 0.4.3-rc.0 - -### Patch Changes - -- ([#35286](https://github.com/RocketChat/Rocket.Chat/pull/35286)) Bumps fuselage and related packages versions to use the most recent releases of each package, especially the fix for the missing track of the fuselage slider component - -## 0.4.2 - -### Patch Changes - -- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases - -## 0.4.2-rc.0 - -### Patch Changes - -- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases - -## 0.4.1 - -### Patch Changes - -- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes an error where the engine would not retry a subprocess restart if the last attempt failed - -- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases - -- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes error propagation when trying to get the status of apps in some cases - -- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes wrong data being reported to total failed apps metrics and statistics - -## 0.4.1-rc.1 - -### Patch Changes - -- ([#34858](https://github.com/RocketChat/Rocket.Chat/pull/34858)) Fixes an issue that prevented the apps-engine from reestablishing communications with subprocesses in some cases - -## 0.4.1-rc.0 - -### Patch Changes - -- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes an error where the engine would not retry a subprocess restart if the last attempt failed - -- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes error propagation when trying to get the status of apps in some cases - -- ([#34205](https://github.com/RocketChat/Rocket.Chat/pull/34205)) Fixes wrong data being reported to total failed apps metrics and statistics - -## 0.4.0 - -### Minor Changes - -- ([#33592](https://github.com/RocketChat/Rocket.Chat/pull/33592)) Adds ability to collapse/expand sidebar groups - -## 0.4.0-rc.0 - -### Minor Changes - -- ([#33592](https://github.com/RocketChat/Rocket.Chat/pull/33592)) Adds ability to collapse/expand sidebar groups - -## 0.3.0 - -### Minor Changes - -- ([#32821](https://github.com/RocketChat/Rocket.Chat/pull/32821)) Replaced new `SidebarV2` components under feature preview - -## 0.3.0-rc.0 - -### Minor Changes - -- ([#32821](https://github.com/RocketChat/Rocket.Chat/pull/32821)) Replaced new `SidebarV2` components under feature preview - -## 0.2.1 - -### Patch Changes - -- ([#32968](https://github.com/RocketChat/Rocket.Chat/pull/32968)) Bumped @rocket.chat/fuselage that fixes the Menu onPointerUp event behavior - -## 0.2.1-rc.0 - -### Patch Changes - -- ([#32968](https://github.com/RocketChat/Rocket.Chat/pull/32968)) Bumped @rocket.chat/fuselage that fixes the Menu onPointerUp event behavior - -## 0.2.0 - -### Minor Changes - -- ([#31821](https://github.com/RocketChat/Rocket.Chat/pull/31821)) New runtime for apps in the Apps-Engine based on the Deno platform - -## 0.2.0-rc.0 - -### Minor Changes - -- ([#31821](https://github.com/RocketChat/Rocket.Chat/pull/31821)) New runtime for apps in the Apps-Engine based on the Deno platform - -## 0.1.2 - -### Patch Changes - -- ([#31138](https://github.com/RocketChat/Rocket.Chat/pull/31138)) feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo - -## 0.1.2-rc.0 - -### Patch Changes - -- b223cbde14: feat(uikit): Move `@rocket.chat/ui-kit` package to the main monorepo - -## 0.1.1 - -### Patch Changes - -- 8e89b5a3b0: fix: light-theme font-disabled color - -## 0.1.1-rc.0 - -### Patch Changes - -- 8e89b5a3b0: fix: light-theme font-disabled color - -## 0.1.0 - -### Minor Changes - -- 357a3a50fa: feat: high-contrast theme - -## 0.1.0-rc.0 - -### Minor Changes - -- 357a3a50fa: feat: high-contrast theme diff --git a/ee/packages/ui-theming/package.json b/ee/packages/ui-theming/package.json deleted file mode 100644 index 6c90a8bc70689..0000000000000 --- a/ee/packages/ui-theming/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@rocket.chat/ui-theming", - "version": "0.4.4", - "private": true, - "main": "./dist/index.js", - "typings": "./dist/index.d.ts", - "files": [ - "/src" - ], - "scripts": { - "build": "rm -rf dist && tsc -p tsconfig-build.json", - "dev": "tsc -p tsconfig-build.json --watch --preserveWatchOutput", - "lint": "eslint .", - "lint:fix": "eslint --fix ." - }, - "devDependencies": { - "@rocket.chat/css-in-js": "~0.31.25", - "@rocket.chat/emitter": "^0.32.0", - "@rocket.chat/fuselage": "^0.73.0", - "@rocket.chat/fuselage-hooks": "^0.40.0", - "@rocket.chat/fuselage-tokens": "~0.33.2", - "@rocket.chat/icons": "~0.47.0", - "@rocket.chat/ui-contexts": "workspace:~", - "@types/react": "~18.3.27", - "@types/react-dom": "~18.3.7", - "eslint": "~9.39.3", - "react": "~18.3.1", - "react-docgen-typescript-plugin": "~1.0.8", - "react-dom": "~18.3.1", - "react-virtuoso": "^4.12.0", - "typescript": "~5.9.3", - "webpack": "~5.99.9" - }, - "peerDependencies": { - "@rocket.chat/css-in-js": "*", - "@rocket.chat/fuselage": "*", - "@rocket.chat/fuselage-hooks": "*", - "@rocket.chat/icons": "*", - "react": "*" - }, - "volta": { - "extends": "../../../package.json" - } -} diff --git a/ee/packages/ui-theming/src/index.ts b/ee/packages/ui-theming/src/index.ts deleted file mode 100644 index a3c440cc2e490..0000000000000 --- a/ee/packages/ui-theming/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './hooks/useThemeMode'; diff --git a/ee/packages/ui-theming/tsconfig-build.json b/ee/packages/ui-theming/tsconfig-build.json deleted file mode 100644 index d65872bd407d6..0000000000000 --- a/ee/packages/ui-theming/tsconfig-build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["**/*.stories.tsx"] -} diff --git a/ee/packages/ui-theming/tsconfig.json b/ee/packages/ui-theming/tsconfig.json deleted file mode 100644 index c910b4f07b9e5..0000000000000 --- a/ee/packages/ui-theming/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@rocket.chat/tsconfig/client.json", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" - }, - "include": ["./src/**/*"] -} diff --git a/packages/ui-client/src/hooks/index.ts b/packages/ui-client/src/hooks/index.ts index 428a4f8d65ee2..1427f4df332df 100644 --- a/packages/ui-client/src/hooks/index.ts +++ b/packages/ui-client/src/hooks/index.ts @@ -4,8 +4,9 @@ export * from './useDontAskAgain'; export * from './useEmbeddedLayout'; export * from './useFeaturePreview'; export * from './useFeaturePreviewList'; +export * from './useGoToDirectMessage'; export * from './useLicense'; export * from './usePreferenceFeaturePreviewList'; +export * from './useThemeMode'; export * from './useUserDisplayName'; export * from './useValidatePassword'; -export * from './useGoToDirectMessage'; diff --git a/packages/ui-client/src/hooks/useThemeMode.spec.ts b/packages/ui-client/src/hooks/useThemeMode.spec.ts new file mode 100644 index 0000000000000..6962adc344b87 --- /dev/null +++ b/packages/ui-client/src/hooks/useThemeMode.spec.ts @@ -0,0 +1,133 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook, act } from '@testing-library/react'; + +import { useThemeMode } from './useThemeMode'; + +const mockUseDarkMode = jest.fn(); + +jest.mock('@rocket.chat/fuselage-hooks', () => ({ + useDarkMode: (...args: unknown[]) => mockUseDarkMode(...args), +})); + +afterEach(() => { + mockUseDarkMode.mockReset(); +}); + +describe('useThemeMode', () => { + describe('current theme mode', () => { + it('should default to "auto" when no preference is set', () => { + mockUseDarkMode.mockReturnValue(false); + + const { result } = renderHook(() => useThemeMode(), { + wrapper: mockAppRoot().build(), + }); + + expect(result.current[0]).toBe('auto'); + }); + + it('should return the user preference when set', () => { + mockUseDarkMode.mockReturnValue(true); + + const { result } = renderHook(() => useThemeMode(), { + wrapper: mockAppRoot().withUserPreference('themeAppearence', 'dark').build(), + }); + + expect(result.current[0]).toBe('dark'); + }); + }); + + describe('resolved theme', () => { + it('should resolve to "light" when mode is "auto" and system prefers light', () => { + mockUseDarkMode.mockReturnValue(false); + + const { result } = renderHook(() => useThemeMode(), { + wrapper: mockAppRoot().withUserPreference('themeAppearence', 'auto').build(), + }); + + expect(result.current[2]).toBe('light'); + expect(mockUseDarkMode).toHaveBeenCalledWith(undefined); + }); + + it('should resolve to "dark" when mode is "auto" and system prefers dark', () => { + mockUseDarkMode.mockReturnValue(true); + + const { result } = renderHook(() => useThemeMode(), { + wrapper: mockAppRoot().withUserPreference('themeAppearence', 'auto').build(), + }); + + expect(result.current[2]).toBe('dark'); + expect(mockUseDarkMode).toHaveBeenCalledWith(undefined); + }); + + it('should resolve to "dark" when mode is "dark"', () => { + mockUseDarkMode.mockReturnValue(true); + + const { result } = renderHook(() => useThemeMode(), { + wrapper: mockAppRoot().withUserPreference('themeAppearence', 'dark').build(), + }); + + expect(result.current[2]).toBe('dark'); + expect(mockUseDarkMode).toHaveBeenCalledWith(true); + }); + + it('should resolve to "light" when mode is "light"', () => { + mockUseDarkMode.mockReturnValue(false); + + const { result } = renderHook(() => useThemeMode(), { + wrapper: mockAppRoot().withUserPreference('themeAppearence', 'light').build(), + }); + + expect(result.current[2]).toBe('light'); + expect(mockUseDarkMode).toHaveBeenCalledWith(false); + }); + + it('should resolve to "high-contrast" when mode is "high-contrast"', () => { + mockUseDarkMode.mockReturnValue(false); + + const { result } = renderHook(() => useThemeMode(), { + wrapper: mockAppRoot().withUserPreference('themeAppearence', 'high-contrast').build(), + }); + + expect(result.current[2]).toBe('high-contrast'); + }); + }); + + describe('setThemeMode', () => { + it('should return a function that calls the endpoint for each theme mode', async () => { + mockUseDarkMode.mockReturnValue(false); + const endpointHandler = jest.fn(); + + const { result } = renderHook(() => useThemeMode(), { + wrapper: mockAppRoot().withEndpoint('POST', '/v1/users.setPreferences', endpointHandler).build(), + }); + + const setTheme = result.current[1]; + + await act(async () => { + setTheme('dark')(); + }); + expect(endpointHandler).toHaveBeenCalledWith({ data: { themeAppearence: 'dark' } }); + + endpointHandler.mockClear(); + + await act(async () => { + setTheme('light')(); + }); + expect(endpointHandler).toHaveBeenCalledWith({ data: { themeAppearence: 'light' } }); + + endpointHandler.mockClear(); + + await act(async () => { + setTheme('auto')(); + }); + expect(endpointHandler).toHaveBeenCalledWith({ data: { themeAppearence: 'auto' } }); + + endpointHandler.mockClear(); + + await act(async () => { + setTheme('high-contrast')(); + }); + expect(endpointHandler).toHaveBeenCalledWith({ data: { themeAppearence: 'high-contrast' } }); + }); + }); +}); diff --git a/ee/packages/ui-theming/src/hooks/useThemeMode.ts b/packages/ui-client/src/hooks/useThemeMode.ts similarity index 91% rename from ee/packages/ui-theming/src/hooks/useThemeMode.ts rename to packages/ui-client/src/hooks/useThemeMode.ts index 8d9078371fedb..49a140d78a4f7 100644 --- a/ee/packages/ui-theming/src/hooks/useThemeMode.ts +++ b/packages/ui-client/src/hooks/useThemeMode.ts @@ -8,7 +8,11 @@ import { useCallback, useState } from 'react'; * @param defaultThemeMode The default theme mode to use if the user has not set any. * @returns [currentThemeMode, setThemeMode, resolvedThemeMode] */ -export const useThemeMode = (): [ThemeMode, (value: ThemeMode) => () => void, Themes] => { +export const useThemeMode = (): [ + currentThemeMode: ThemeMode, + setThemeMode: (value: ThemeMode) => () => void, + resolvedThemeMode: Themes, +] => { const themeMode = useUserPreference('themeAppearence') || 'auto'; const saveUserPreferences = useEndpoint('POST', '/v1/users.setPreferences'); diff --git a/yarn.lock b/yarn.lock index ef1cc5b3ab342..f49420cef9f99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9850,7 +9850,6 @@ __metadata: "@rocket.chat/ui-composer": "workspace:^" "@rocket.chat/ui-contexts": "workspace:^" "@rocket.chat/ui-kit": "workspace:~" - "@rocket.chat/ui-theming": "workspace:^" "@rocket.chat/ui-video-conf": "workspace:^" "@rocket.chat/ui-voip": "workspace:^" "@rocket.chat/web-ui-registration": "workspace:^" @@ -10951,35 +10950,6 @@ __metadata: languageName: unknown linkType: soft -"@rocket.chat/ui-theming@workspace:^, @rocket.chat/ui-theming@workspace:ee/packages/ui-theming": - version: 0.0.0-use.local - resolution: "@rocket.chat/ui-theming@workspace:ee/packages/ui-theming" - dependencies: - "@rocket.chat/css-in-js": "npm:~0.31.25" - "@rocket.chat/emitter": "npm:^0.32.0" - "@rocket.chat/fuselage": "npm:^0.73.0" - "@rocket.chat/fuselage-hooks": "npm:^0.40.0" - "@rocket.chat/fuselage-tokens": "npm:~0.33.2" - "@rocket.chat/icons": "npm:~0.47.0" - "@rocket.chat/ui-contexts": "workspace:~" - "@types/react": "npm:~18.3.27" - "@types/react-dom": "npm:~18.3.7" - eslint: "npm:~9.39.3" - react: "npm:~18.3.1" - react-docgen-typescript-plugin: "npm:~1.0.8" - react-dom: "npm:~18.3.1" - react-virtuoso: "npm:^4.12.0" - typescript: "npm:~5.9.3" - webpack: "npm:~5.99.9" - peerDependencies: - "@rocket.chat/css-in-js": "*" - "@rocket.chat/fuselage": "*" - "@rocket.chat/fuselage-hooks": "*" - "@rocket.chat/icons": "*" - react: "*" - languageName: unknown - linkType: soft - "@rocket.chat/ui-video-conf@workspace:*, @rocket.chat/ui-video-conf@workspace:^, @rocket.chat/ui-video-conf@workspace:packages/ui-video-conf": version: 0.0.0-use.local resolution: "@rocket.chat/ui-video-conf@workspace:packages/ui-video-conf" @@ -32490,7 +32460,7 @@ __metadata: languageName: node linkType: hard -"react-docgen-typescript-plugin@npm:^1.0.8, react-docgen-typescript-plugin@npm:~1.0.8": +"react-docgen-typescript-plugin@npm:^1.0.8": version: 1.0.8 resolution: "react-docgen-typescript-plugin@npm:1.0.8" dependencies: From cfcff9966c2d9abb4f1866e80441edbf3c3acf13 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 13 Mar 2026 09:48:56 -0600 Subject: [PATCH 2/2] fix: Forwarding messages to channels with join code (#39541) --- .changeset/new-students-attack.md | 5 + .../getRoomByNameOrIdWithOptionToJoin.ts | 7 +- apps/meteor/tests/end-to-end/api/chat.ts | 48 +- .../getRoomByNameOrIdWithOptionToJoin.spec.ts | 518 ++++++++++++++++++ 4 files changed, 575 insertions(+), 3 deletions(-) create mode 100644 .changeset/new-students-attack.md create mode 100644 apps/meteor/tests/unit/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.spec.ts diff --git a/.changeset/new-students-attack.md b/.changeset/new-students-attack.md new file mode 100644 index 0000000000000..b1d4b56d505e1 --- /dev/null +++ b/.changeset/new-students-attack.md @@ -0,0 +1,5 @@ +--- +"@rocket.chat/meteor": patch +--- + +Fixes an issue when forwarding messages to a password-protected room. diff --git a/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts b/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts index 6217342d0c534..d6afca615515b 100644 --- a/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts +++ b/apps/meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts @@ -1,6 +1,6 @@ import { Room } from '@rocket.chat/core-services'; import type { IRoom, IUser, RoomType } from '@rocket.chat/core-typings'; -import { Rooms, Users } from '@rocket.chat/models'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import { isObject } from '../../../../lib/utils/isObject'; @@ -88,7 +88,10 @@ export const getRoomByNameOrIdWithOptionToJoin = async ({ // If the room type is channel and joinChannel has been passed, try to join them // if they can't join the room, this will error out! if (room.t === 'c' && joinChannel) { - await Room.join({ room, user }); + const sub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } }); + if (!sub) { + await Room.join({ room, user }); + } } return room; diff --git a/apps/meteor/tests/end-to-end/api/chat.ts b/apps/meteor/tests/end-to-end/api/chat.ts index 15ab7e4c083cb..7fd725ed5bfbc 100644 --- a/apps/meteor/tests/end-to-end/api/chat.ts +++ b/apps/meteor/tests/end-to-end/api/chat.ts @@ -28,14 +28,32 @@ const pinMessage = ({ msgId }: { msgId: IMessage['_id'] }) => { describe('[Chat]', () => { let testChannel: IRoom; let message: { _id: IMessage['_id'] }; + let protectedChannel: IRoom; before((done) => getCredentials(done)); before(async () => { testChannel = (await createRoom({ type: 'c', name: `chat.api-test-${Date.now()}` })).body.channel; + protectedChannel = (await createRoom({ type: 'c', name: `chat.api-protected-test-${Date.now()}` })).body.channel; + + await request + .post(api('rooms.saveRoomSettings')) + .set(credentials) + .send({ + rid: protectedChannel._id, + joinCode: 'super-secret-password', + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); }); - after(() => deleteRoom({ type: 'c', roomId: testChannel._id })); + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + await deleteRoom({ type: 'c', roomId: protectedChannel._id }); + }); describe('/chat.postMessage', () => { it('should throw an error when at least one of required parameters(channel, roomId) is not sent', (done) => { @@ -586,6 +604,34 @@ describe('[Chat]', () => { .end(done); }); + it('should allow forwarding a message into the same password protected room', async () => { + const postResponse = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: [protectedChannel._id], + text: 'Message to be forwarded', + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(postResponse.body).to.have.property('success', true); + const originalMessageId = postResponse.body.message._id as IMessage['_id']; + + const forwardResponse = await request + .post(api('chat.postMessage')) + .set(credentials) + .send({ + roomId: [protectedChannel._id], + text: `[](http://localhost:3000/channel/${protectedChannel.name}?msg=${originalMessageId}`, + }) + .expect('Content-Type', 'application/json') + .expect(200); + + expect(forwardResponse.body).to.have.property('success', true); + expect(forwardResponse.body).to.have.nested.property('message.rid', protectedChannel._id); + }); + it('should return statusCode 200 when postMessage successfully', (done) => { void request .post(api('chat.postMessage')) diff --git a/apps/meteor/tests/unit/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.spec.ts b/apps/meteor/tests/unit/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.spec.ts new file mode 100644 index 0000000000000..25d9a03972c15 --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.spec.ts @@ -0,0 +1,518 @@ +import { expect } from 'chai'; +import { describe, it, beforeEach, afterEach } from 'mocha'; +import proxyquire from 'proxyquire'; +import Sinon from 'sinon'; + +type RoomType = 'c' | 'p' | 'd' | 'l'; + +interface IUser { + _id: string; + username: string; + federated?: boolean; + federation?: any; +} + +interface IRoom { + _id: string; + t: RoomType; + name?: string; +} + +type GetRoomByNameOrIdWithOptionToJoinFn = (params: { + user: Pick; + nameOrId: string; + type?: RoomType; + tryDirectByUserIdOnly?: boolean; + joinChannel?: boolean; + errorOnEmpty?: boolean; +}) => Promise; + +const RoomsStub = { + findOneByIdOrName: Sinon.stub(), + findOneDirectRoomContainingAllUserIDs: Sinon.stub(), + findOneById: Sinon.stub(), +}; + +const SubscriptionsStub = { + findOneByRoomIdAndUserId: Sinon.stub(), +}; + +const UsersStub = { + findOneById: Sinon.stub(), + findOne: Sinon.stub(), +}; + +const RoomServiceStub = { + join: Sinon.stub().resolves(), +}; + +const MeteorStub = { + Meteor: { + Error: Sinon.stub().callsFake(function (this: any, code: string) { + this.error = code; + this.errorType = 'Meteor.Error'; + } as any), + }, +}; + +const createDirectMessageStub = Sinon.stub().resolves({ rid: 'newDirectRoomId' }); + +const isObjectMock = { + isObject(obj: unknown) { + return obj !== null && typeof obj === 'object'; + }, +}; + +const { + getRoomByNameOrIdWithOptionToJoin, +}: { + getRoomByNameOrIdWithOptionToJoin: GetRoomByNameOrIdWithOptionToJoinFn; +} = proxyquire.noCallThru().load('../../../../../../../meteor/app/lib/server/functions/getRoomByNameOrIdWithOptionToJoin.ts', { + '@rocket.chat/models': { + Rooms: RoomsStub, + Subscriptions: SubscriptionsStub, + Users: UsersStub, + }, + '@rocket.chat/core-services': { + Room: RoomServiceStub, + }, + '../../../../lib/utils/isObject': isObjectMock, + '../../../../server/methods/createDirectMessage': { + createDirectMessage: createDirectMessageStub, + }, + 'meteor/meteor': MeteorStub, +}) as any; + +describe('getRoomByNameOrIdWithOptionToJoin', () => { + const baseUser: IUser = { + _id: 'userId', + username: 'user', + }; + + beforeEach(() => { + RoomsStub.findOneByIdOrName.reset(); + RoomsStub.findOneDirectRoomContainingAllUserIDs.reset(); + RoomsStub.findOneById.reset(); + + SubscriptionsStub.findOneByRoomIdAndUserId.reset(); + + UsersStub.findOneById.reset(); + UsersStub.findOne.reset(); + + RoomServiceStub.join.reset(); + RoomServiceStub.join.resolves(); + + MeteorStub.Meteor.Error.resetBehavior(); + MeteorStub.Meteor.Error.resetHistory(); + MeteorStub.Meteor.Error.callsFake(function (this: any, code: string) { + this.error = code; + this.errorType = 'Meteor.Error'; + } as any); + + createDirectMessageStub.reset(); + createDirectMessageStub.resolves({ rid: 'newDirectRoomId' }); + }); + + afterEach(() => { + Sinon.restore(); + }); + + describe('channel / group resolution', () => { + it('should strip leading # and lookup by id or name', async () => { + const room: IRoom = { _id: 'room1', t: 'c', name: 'general' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '#general', + type: undefined, + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + + expect(RoomsStub.findOneByIdOrName.calledOnceWithExactly('general')).to.equal(true); + expect(result).to.equal(room); + }); + + it('should treat name without # as channel/group and call findOneByIdOrName', async () => { + const room: IRoom = { _id: 'room2', t: 'c', name: 'random' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'random', + type: undefined, + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + + expect(RoomsStub.findOneByIdOrName.calledOnceWithExactly('random')).to.equal(true); + expect(result).to.equal(room); + }); + + it('should throw Meteor.Error("invalid-channel") when channel not found and errorOnEmpty=true', async () => { + RoomsStub.findOneByIdOrName.resolves(null); + + let caught: any; + try { + await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'nonexistent', + type: undefined, + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + } catch (e) { + caught = e; + } + + expect(caught).to.be.an('object'); + expect(caught.error).to.equal('invalid-channel'); + }); + + it('should return null when channel not found and errorOnEmpty=false', async () => { + RoomsStub.findOneByIdOrName.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'nonexistent', + type: undefined, + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: false, + }); + + expect(result).to.equal(null); + }); + }); + + describe('type filtering', () => { + it('should return room when type matches', async () => { + const room: IRoom = { _id: 'room3', t: 'c', name: 'dev' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'dev', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + + expect(result).to.equal(room); + }); + + it('should throw Meteor.Error when type does not match and errorOnEmpty=true', async () => { + const room: IRoom = { _id: 'room4', t: 'p', name: 'private' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + let caught: any; + try { + await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'private', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + } catch (e) { + caught = e; + } + + expect(caught).to.be.an('object'); + expect(caught.error).to.equal('invalid-channel'); + }); + + it('should return null when type does not match and errorOnEmpty=false', async () => { + const room: IRoom = { _id: 'room5', t: 'p', name: 'private' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'private', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: false, + }); + + expect(result).to.equal(null); + }); + }); + + describe('joining channel behaviour', () => { + it('should call Room.join when room is channel, joinChannel=true, and user not subscribed', async () => { + const room: IRoom = { _id: 'room6', t: 'c', name: 'joinable' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'joinable', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(SubscriptionsStub.findOneByRoomIdAndUserId.calledOnceWithExactly('room6', baseUser._id, { projection: { _id: 1 } })).to.equal( + true, + ); + expect( + RoomServiceStub.join.calledOnceWithExactly({ + room, + user: baseUser, + }), + ).to.equal(true); + expect(result).to.equal(room); + }); + + it('should not call Room.join when joinChannel=false', async () => { + const room: IRoom = { _id: 'room7', t: 'c', name: 'nojoin' }; + RoomsStub.findOneByIdOrName.resolves(room); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'nojoin', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: false, + errorOnEmpty: true, + }); + + expect(SubscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.equal(true); + expect(RoomServiceStub.join.notCalled).to.equal(true); + expect(result).to.equal(room); + }); + + it('should not call Room.join when room type is not channel', async () => { + const room: IRoom = { _id: 'room8', t: 'p', name: 'private' }; + RoomsStub.findOneByIdOrName.resolves(room); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'private', + type: 'p', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(SubscriptionsStub.findOneByRoomIdAndUserId.notCalled).to.equal(true); + expect(RoomServiceStub.join.notCalled).to.equal(true); + expect(result).to.equal(room); + }); + + it('should not call Room.join when user already subscribed', async () => { + const room: IRoom = { _id: 'room9', t: 'c', name: 'joined' }; + RoomsStub.findOneByIdOrName.resolves(room); + SubscriptionsStub.findOneByRoomIdAndUserId.resolves({ _id: 'subId' }); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'joined', + type: 'c', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(SubscriptionsStub.findOneByRoomIdAndUserId.calledOnceWithExactly('room9', baseUser._id, { projection: { _id: 1 } })).to.equal( + true, + ); + expect(RoomServiceStub.join.notCalled).to.equal(true); + expect(result).to.equal(room); + }); + }); + + describe('direct message resolution by username', () => { + it('should find direct room with existing roomUser and existing DM room', async () => { + const otherUser: IUser = { _id: 'otherId', username: 'other' }; + + UsersStub.findOne.resolves(otherUser); + const dmRoom: IRoom = { _id: 'dm1', t: 'd' }; + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '@other', + type: 'd', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect( + UsersStub.findOne.calledOnceWithExactly({ + $or: [{ _id: 'other' }, { username: 'other' }], + }), + ).to.equal(true); + expect(RoomsStub.findOneDirectRoomContainingAllUserIDs.calledOnce).to.equal(true); + + const callArgs = RoomsStub.findOneDirectRoomContainingAllUserIDs.getCall(0).args[0]; + expect(callArgs).to.be.an('array'); + expect(callArgs.sort()).to.deep.equal(['otherId', 'userId'].sort()); + expect(result).to.equal(dmRoom); + }); + + it('should strip leading @ when resolving DM', async () => { + const otherUser: IUser = { _id: 'otherId2', username: 'other2' }; + UsersStub.findOne.resolves(otherUser); + const dmRoom: IRoom = { _id: 'dm2', t: 'd' }; + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '@other2', + type: undefined, + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect( + UsersStub.findOne.calledOnceWithExactly({ + $or: [{ _id: 'other2' }, { username: 'other2' }], + }), + ).to.equal(true); + expect(result).to.equal(dmRoom); + }); + + it('should treat nameOrId as DM when type is d even without @', async () => { + const otherUser: IUser = { _id: 'otherId3', username: 'plain' }; + UsersStub.findOne.resolves(otherUser); + const dmRoom: IRoom = { _id: 'dm3', t: 'd' }; + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'plain', + type: 'd', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect( + UsersStub.findOne.calledOnceWithExactly({ + $or: [{ _id: 'plain' }, { username: 'plain' }], + }), + ).to.equal(true); + expect(result).to.equal(dmRoom); + }); + }); + + describe('direct message resolution with tryDirectByUserIdOnly', () => { + it('should lookup user by id only when tryDirectByUserIdOnly=true', async () => { + const otherUser: IUser = { _id: 'idOnly', username: 'ignored' }; + UsersStub.findOneById.resolves(otherUser); + const dmRoom: IRoom = { _id: 'dm4', t: 'd' }; + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(dmRoom); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'idOnly', + type: 'd', + tryDirectByUserIdOnly: true, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(UsersStub.findOneById.calledOnceWithExactly('idOnly')).to.equal(true); + expect(UsersStub.findOne.notCalled).to.equal(true); + expect(result).to.equal(dmRoom); + }); + + it('should fallback to Rooms.findOneById when roomUser is falsy but room exists by id', async () => { + UsersStub.findOneById.resolves(null); + const dmRoom: IRoom = { _id: 'idRoom', t: 'd' }; + RoomsStub.findOneById.resolves(dmRoom); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: 'idRoom', + type: 'd', + tryDirectByUserIdOnly: true, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(RoomsStub.findOneById.calledOnceWithExactly('idRoom')).to.equal(true); + expect(result).to.equal(dmRoom); + }); + }); + + describe('direct message creation when roomUser exists but room does not', () => { + it('should throw Meteor.Error when roomUser not found and errorOnEmpty=true', async () => { + UsersStub.findOne.resolves(null); + RoomsStub.findOneById.resolves(null); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(null); + + let caught: any; + try { + await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '@missing', + type: 'd', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + } catch (e) { + caught = e; + } + + expect(caught).to.be.an('object'); + expect(caught.error).to.equal('invalid-channel'); + expect(createDirectMessageStub.notCalled).to.equal(true); + }); + + it('should return null when roomUser not found and errorOnEmpty=false', async () => { + UsersStub.findOne.resolves(null); + RoomsStub.findOneById.resolves(null); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(null); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '@missing', + type: 'd', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: false, + }); + + expect(result).to.equal(null); + expect(createDirectMessageStub.notCalled).to.equal(true); + }); + + it('should create a new direct message when roomUser exists and room is not found', async () => { + const otherUser: IUser = { _id: 'otherCreate', username: 'createMe' }; + UsersStub.findOne.resolves(otherUser); + RoomsStub.findOneDirectRoomContainingAllUserIDs.resolves(null); + RoomsStub.findOneById.resolves({ _id: 'newDirectRoomId', t: 'd' }); + + const result = await getRoomByNameOrIdWithOptionToJoin({ + user: baseUser, + nameOrId: '@createMe', + type: 'd', + tryDirectByUserIdOnly: false, + joinChannel: true, + errorOnEmpty: true, + }); + + expect(createDirectMessageStub.calledOnceWithExactly(['createMe'], baseUser._id)).to.equal(true); + expect(RoomsStub.findOneById.calledWith('newDirectRoomId')).to.equal(true); + expect(result).to.deep.equal({ _id: 'newDirectRoomId', t: 'd' }); + }); + }); +});