From 33aea75a82aef935c3402b94d82f6d073b78ea07 Mon Sep 17 00:00:00 2001 From: albuquerquefabio Date: Tue, 21 Jun 2022 15:45:38 -0300 Subject: [PATCH] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 7857ff483ad38d66083c9f5d9ff93875a2f6aa32 Author: Guilherme Gazzo Date: Tue Jun 21 13:34:13 2022 -0300 Chore: Improve CI cache (#25907) Co-authored-by: Diego Sampaio commit d1c59c36586e45f81def07555c1b53d61e8cc52e Author: Henrique Guimarães Ribeiro Date: Tue Jun 21 12:46:46 2022 -0300 Regression: Re-add view logs button (#25876) commit 8e7135be0fcf98c303217d9ff43cff9167d91024 Author: Tasso Evangelista Date: Tue Jun 21 12:41:09 2022 -0300 Chore: `@rocket.chat/favicon` (#25920) commit dfd1d77674a4a40acea397e50f4fa5355aeaebe2 Author: Hugo Costa Date: Tue Jun 21 12:26:00 2022 -0300 [FIX] VOIP CallContext snapshot infinite loop (#25947) The application was crashing due to an error on the `useCallerInfo()` hook. The error was: ![image](https://user-images.githubusercontent.com/20212776/174823914-4832e5dd-c91a-4ae4-9d1f-1b960bcd372c.png) ![image](https://user-images.githubusercontent.com/20212776/174823982-cb543fe0-663f-4530-bb94-0720653ca897.png) To prevent this issue to happen it was added a cached and out-of-scope snapshot variable to the hook using `useSyncExternalStore` 1. Open rocket.chat server 2. Enable Omnichannel 3. Enable Voip 4. Refresh de page commit 63d4e30f0e55226a85782cf866bf58822bf4e3f9 Author: Diego Sampaio Date: Mon Jun 20 19:08:04 2022 -0300 Use correct Docker image name on publish commit 9d319efd7df0b715a7761a41ee7911c069c2eb35 Author: Diego Sampaio Date: Mon Jun 20 17:56:02 2022 -0300 Regression: Docker image publish (#25931) commit 668e6d4ec8045f172ee57a0996565160a7a01ffc Author: souzaramon Date: Mon Jun 20 16:54:41 2022 -0300 Revert "Chore: Collect e2e coverage (#25743)" (#25936) This reverts commit e43d0d2b795b6c1f9b96f92285e9c1d87f3be3df. commit e43d0d2b795b6c1f9b96f92285e9c1d87f3be3df Author: souzaramon Date: Mon Jun 20 14:23:48 2022 -0300 Chore: Collect e2e coverage (#25743) - collect e2e coverage - publish to artifacts - publish badges to gh-pages commit 48e960cc1c6cf296c8502e9d6db5ded95720c76d Author: Murtaza Patrawala <34130764+murtaza98@users.noreply.github.com> Date: Mon Jun 20 22:37:26 2022 +0530 Regression: Unable to edit user details via admin panel (#25923) commit 9a41c4fa343ed6ab7e2468d2a9ddea23e38d0323 Author: Douglas Fabris Date: Mon Jun 20 10:38:15 2022 -0300 [FIX] Members selection field on creating team modal (#25871) - Fix: add members breaking when searching users ![image](https://user-images.githubusercontent.com/27704687/121788070-b792f700-cba0-11eb-92b9-5833e1213c74.png) Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> commit acb74f1c5c59a2490d1b190f65afe7624e884c42 Author: Martin Schoeler Date: Mon Jun 20 10:17:49 2022 -0300 Chore: Remove Imperative Modal from context (#25911) This PR revises the usage of the modal inside the call provider, by moving the modal provider a little bit up the three. commit d0078fefabc3622e99df97693de907b9aaf9cbba Author: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Mon Jun 20 09:56:56 2022 -0300 Chore: Keep the option to run only the meteor app (#25915) commit d6f4d1b23847e6375264d4ac3a7364bb6946ec7d Author: Kevin Aleman Date: Mon Jun 20 04:57:06 2022 -0600 [FIX] Update chartjs usage to v3 (#25873) commit 8637ba263099579fe2b470d84558ee78b059fdff Author: csuadev <72958726+csuadev@users.noreply.github.com> Date: Mon Jun 20 02:33:55 2022 -0500 Chore: Rewrite AddUsers to TS (#25830) Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com> Co-authored-by: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> commit 086275180170d3f8318e2c5f31a7854ae1b79658 Author: Tasso Evangelista Date: Fri Jun 17 22:09:23 2022 -0300 Chore: Replace `useSubscription` with `useSyncExternalStore` (#25909) commit eedc18b3d97d086324c0248963d8a770720d7c6c Author: Diego Sampaio Date: Fri Jun 17 18:00:04 2022 -0300 Chore: Run tests on docker (#25556) Co-authored-by: Guilherme Gazzo commit 391bb8cc2092961621dccc370915508c1286a951 Author: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com> Date: Fri Jun 17 17:32:56 2022 -0300 Chore: Convert RoomMenu (#25914) commit 610670cadfb921b6454503c413ffffbfb9d1c850 Author: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Fri Jun 17 16:17:18 2022 -0300 [NEW] Create Team with a member list of usernames (#25868) Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com> commit 92e3230788b7517dc6bcebc681b65f16d83a508f Author: Júlia Jaeger Foresti <60678893+juliajforesti@users.noreply.github.com> Date: Fri Jun 17 15:38:21 2022 -0300 Chore: Convert apps/meteor/client/sidebar/search (#25754) commit b46cb69e2696bd0908eb7f9e37dcec88eac208f6 Author: Douglas Fabris Date: Fri Jun 17 15:38:09 2022 -0300 Chore: Split useUserInfoActions into small hooks (#25747) commit 06043f52cc947cdbb94e46f38786683e5b4f6ef6 Author: gabriellsh <40830821+gabriellsh@users.noreply.github.com> Date: Fri Jun 17 13:31:14 2022 -0300 Chore: Watch for package changes (#25910) With the current `dev` pipeline, whenever we modify a package (e.g. `api-client`), we have to kill the meteor proccess and run `yarn dev` again in order for the changes to be compiled and the new output to be used by meteor. This has the drawback of taking a little longer to run the dev environment, since we can't cache a watched buid. In the other hand, it reduces the friction of modifying internal packages since we don't need to rebuild the project for changes to take effect. This will enable us to move more things to separate packages without affecting the dev experience too much. commit 42ae7aa8292fcd70a8c880b0b54d77d5fbfa88e4 Author: Marcos Spessatto Defendi Date: Fri Jun 17 13:22:44 2022 -0300 Chore: Convert assets endpoint to Typescript (#25358) Co-authored-by: Guilherme Gazzo Co-authored-by: Guilherme Gazzo commit 6b6f9dcb74dcfea8e9355d8ecab925c0801c4a26 Author: Guilherme Gazzo Date: Fri Jun 17 10:37:17 2022 -0300 Chore: Convert users endpoints (#25635) Co-authored-by: Guilherme Gazzo Co-authored-by: Tasso Evangelista commit 2c05ffd12647f2685efeccf142477c158e7af844 Author: Diego Sampaio Date: Fri Jun 17 09:59:27 2022 -0300 [FIX] Settings not being overwritten to their default values (#25891) --- .../build-docker-image-service/action.yml | 69 ++ .github/actions/build-docker-image/action.yml | 88 ++ .github/workflows/build_and_test.yml | 958 ++++++++------- .yarnrc.yml | 3 +- _templates/package/new/package.json.ejs.t | 3 +- apps/meteor/.docker/Dockerfile.alpine | 2 +- apps/meteor/.eslintignore | 1 - apps/meteor/.gitignore | 1 + apps/meteor/.meteorignore | 1 + apps/meteor/.scripts/start.js | 196 ---- apps/meteor/app/api/server/api.d.ts | 11 +- apps/meteor/app/api/server/lib/users.ts | 15 +- .../api/server/v1/{assets.js => assets.ts} | 26 +- apps/meteor/app/api/server/v1/teams.ts | 8 +- .../app/api/server/v1/{users.js => users.ts} | 1041 ++++++++--------- .../assets/server/{assets.js => assets.ts} | 205 ++-- .../app/assets/server/{index.js => index.ts} | 0 apps/meteor/app/favico/client/favico.js | 844 ------------- apps/meteor/app/favico/client/index.js | 3 - apps/meteor/app/favico/index.js | 1 - ...{getFullUserData.js => getFullUserData.ts} | 40 +- .../app/lib/server/functions/setUserAvatar.ts | 22 +- .../app/livechat/client/lib/chartHandler.js | 221 ---- .../app/livechat/client/lib/chartHandler.ts | 232 ++++ apps/meteor/app/models/server/raw/Users.js | 9 + .../app/settings/server/SettingsRegistry.ts | 18 +- apps/meteor/app/utils/lib/mimeTypes.js | 8 - apps/meteor/app/utils/lib/mimeTypes.ts | 14 + .../components/Header/Header.stories.tsx | 13 +- .../client/components/Omnichannel/Tags.tsx | 5 +- .../context/OmnichannelRoomIconContext.tsx | 21 +- .../provider/OmnichannelRoomIconProvider.tsx | 32 +- .../VerticalBar/VerticalBarActionBack.tsx | 9 - .../VerticalBar/VerticalBarBack.tsx | 13 + .../client/components/VerticalBar/index.ts | 4 +- .../components/voip/modal/WrapUpCallModal.tsx | 8 +- apps/meteor/client/contexts/CallContext.ts | 38 +- apps/meteor/client/hooks/usePresence.ts | 25 +- apps/meteor/client/hooks/useReactiveValue.ts | 16 +- apps/meteor/client/hooks/useUserData.ts | 25 +- apps/meteor/client/importPackages.ts | 1 - apps/meteor/client/lib/RoomManager.ts | 44 +- apps/meteor/client/lib/appLayout.ts | 7 +- apps/meteor/client/lib/banners.ts | 9 +- apps/meteor/client/lib/createSidebarItems.ts | 22 +- .../client/lib/createValueSubscription.ts | 22 - .../meteor/client/lib/portals/blazePortals.ts | 12 +- .../client/lib/portals/portalsSubscription.ts | 12 +- apps/meteor/client/lib/presence.ts | 14 - .../providers/CallProvider/CallProvider.tsx | 10 +- .../client/providers/MeteorProvider.tsx | 12 +- .../client/providers/RouterProvider.tsx | 11 +- .../createReactiveSubscriptionFactory.ts | 15 +- .../RoomList/SideBarItemTemplateWithData.tsx | 4 +- .../sidebar/{RoomMenu.js => RoomMenu.tsx} | 66 +- .../meteor/client/sidebar/Sidebar.stories.tsx | 33 +- .../client/sidebar/search/{Row.js => Row.tsx} | 11 +- .../sidebar/search/ScrollerWithCustomProps.js | 16 - .../search/ScrollerWithCustomProps.tsx | 16 + .../search/{SearchList.js => SearchList.tsx} | 137 ++- .../search/{UserItem.js => UserItem.tsx} | 24 +- apps/meteor/client/startup/unread.ts | 15 +- .../client/views/account/AccountSidebar.tsx | 6 +- apps/meteor/client/views/account/index.ts | 2 +- .../client/views/account/sidebarItems.ts | 3 +- .../views/admin/EditableSettingsContext.ts | 53 +- .../meteor/client/views/admin/apps/AppMenu.js | 32 +- .../views/admin/sidebar/AdminSidebarPages.tsx | 7 +- .../meteor/client/views/admin/sidebarItems.ts | 3 +- .../client/views/admin/users/EditUser.js | 1 - .../client/views/banners/BannerRegion.tsx | 4 +- .../client/views/hooks/useActionSpread.ts | 14 +- .../views/omnichannel/additionalForms.tsx | 23 +- .../views/omnichannel/agents/AgentEdit.tsx | 5 +- .../analytics/InterchangeableChart.js | 12 +- .../BusinessHoursFormContainer.js | 5 +- .../omnichannel/currentChats/FilterByText.tsx | 5 +- .../customFields/EditCustomFieldsPage.js | 5 +- .../customFields/NewCustomFieldsPage.js | 5 +- .../omnichannel/departments/EditDepartment.js | 5 +- .../directory/chats/contextualBar/RoomEdit.js | 5 +- .../contacts/contextualBar/ContactNewEdit.js | 5 +- .../charts/AgentStatusChart.js | 2 +- .../charts/ChatDurationChart.js | 12 +- .../charts/ChatsPerAgentChart.js | 1 + .../charts/ResponseTimesChart.js | 12 +- .../sidebar/OmnichannelSidebar.tsx | 6 +- .../client/views/omnichannel/sidebarItems.ts | 3 +- .../contexts/SelectedMessagesContext.tsx | 50 +- .../providers/MessageHighlightProvider.tsx | 6 +- .../providers/messageHighlightSubscription.ts | 27 +- .../RoomMembers/AddUsers/AddUsers.stories.tsx | 2 +- .../AddUsers/{AddUsers.js => AddUsers.tsx} | 17 +- ...dUsersWithData.js => AddUsersWithData.tsx} | 21 +- .../views/room/hooks/useUserHasRoomRole.ts | 8 + .../views/room/hooks/useUserInfoActions.js | 415 ------- .../actions/useAudioCallAction.ts | 33 + .../actions/useBlockUserAction.ts | 51 + .../actions/useChangeLeaderAction.ts | 53 + .../actions/useChangeModeratorAction.ts | 54 + .../actions/useChangeOwnerAction.tsx | 53 + .../actions/useDirectMessageAction.ts | 54 + .../actions/useIgnoreUserAction.ts | 52 + .../actions/useMuteUserAction.tsx | 119 ++ .../actions/useRemoveUserAction.tsx | 92 ++ .../actions/useVideoCallAction.tsx | 33 + .../room/hooks/useUserInfoActions/index.ts | 1 + .../useUserInfoActions/useUserInfoActions.ts | 60 + .../views/room/lib/getRoomDirectives.ts | 33 + apps/meteor/client/views/root/AppLayout.tsx | 6 +- .../client/views/root/PortalsWrapper.tsx | 4 +- .../teams/CreateTeamModal/CreateTeamModal.tsx | 211 +--- .../teams/CreateTeamModal/UsersInput.tsx | 90 -- .../useCreateTeamModalState.ts | 193 +++ .../client/views/teams/{index.js => index.ts} | 0 .../definition/externals/meteor/meteor.d.ts | 4 + .../externals/meteor/webapp-hashing.d.ts | 5 + apps/meteor/package.json | 10 +- .../rocketchat-i18n/i18n/en.i18n.json | 2 + apps/meteor/server/sdk/types/ITeamService.ts | 1 + apps/meteor/server/services/team/service.ts | 16 +- .../e2e/utils/configs/verifyTestBaseUrl.ts | 3 - apps/meteor/tests/end-to-end/api/01-users.js | 2 +- apps/meteor/tests/end-to-end/api/25-teams.js | 41 +- .../tests/mocks/client/RouterContextMock.tsx | 41 +- apps/meteor/tests/unit/lib/mimeTypes.tests.ts | 15 + package.json | 4 +- packages/api-client/package.json | 3 +- packages/core-typings/package.json | 1 + .../core-typings/src/IRocketChatAssets.ts | 54 + packages/core-typings/src/ISetting.ts | 6 +- packages/core-typings/src/ISubscription.ts | 2 +- packages/core-typings/src/IUser.ts | 1 + packages/core-typings/src/index.ts | 1 + packages/favicon/.eslintrc | 8 + packages/favicon/package.json | 20 + packages/favicon/src/badge.ts | 87 ++ packages/favicon/src/index.ts | 111 ++ packages/favicon/tsconfig.json | 9 + packages/livechat/package.json | 1 + packages/rest-typings/package.json | 1 + packages/rest-typings/src/index.ts | 9 + packages/rest-typings/src/v1/assets.ts | 28 + packages/rest-typings/src/v1/groups.ts | 18 + packages/rest-typings/src/v1/users.ts | 233 +++- .../src/v1/users/UserCreateParamsPOST.ts | 51 + .../v1/users/UserDeactivateIdleParamsPOST.ts | 26 + .../src/v1/users/UserLogoutParamsPOST.ts | 22 + .../src/v1/users/UserRegisterParamsPOST.ts | 47 + .../v1/users/UserSetActiveStatusParamsPOST.ts | 24 + .../v1/users/UsersAutocompleteParamsGET.ts | 20 + .../src/v1/users/UsersInfoParamsGet.ts | 44 + .../src/v1/users/UsersListTeamsParamsGET.ts | 21 + .../src/v1/users/UsersSetAvatarParamsPOST.ts | 33 + .../v1/users/UsersSetPreferenceParamsPOST.ts | 190 +++ .../UsersUpdateOwnBasicInfoParamsPOST.ts | 67 ++ .../src/v1/users/UsersUpdateParamsPOST.ts | 118 ++ packages/ui-contexts/package.json | 9 +- .../ui-contexts/src/AuthorizationContext.ts | 38 +- packages/ui-contexts/src/RouterContext.ts | 41 +- .../ui-contexts/src/ServerContext/methods.ts | 20 + packages/ui-contexts/src/SessionContext.ts | 8 +- packages/ui-contexts/src/SettingsContext.ts | 19 +- packages/ui-contexts/src/UserContext.ts | 43 +- .../src/hooks/useAllPermissions.ts | 6 +- .../src/hooks/useAtLeastOnePermission.ts | 9 +- .../ui-contexts/src/hooks/useCurrentRoute.ts | 5 +- .../ui-contexts/src/hooks/usePermission.ts | 6 +- .../src/hooks/useQueryStringParameter.ts | 6 +- packages/ui-contexts/src/hooks/useRole.ts | 6 +- .../src/hooks/useRolesDescription.ts | 14 +- packages/ui-contexts/src/hooks/useRoute.ts | 5 +- .../src/hooks/useRouteParameter.ts | 6 +- .../ui-contexts/src/hooks/useRoutePath.ts | 9 +- packages/ui-contexts/src/hooks/useRouteUrl.ts | 9 +- packages/ui-contexts/src/hooks/useSession.ts | 6 +- .../src/hooks/useSettingStructure.ts | 6 +- packages/ui-contexts/src/hooks/useSettings.ts | 6 +- .../src/hooks/useUserPreference.ts | 6 +- packages/ui-contexts/src/hooks/useUserRoom.ts | 6 +- .../src/hooks/useUserSubscription.ts | 6 +- .../src/hooks/useUserSubscriptionByName.ts | 8 +- .../src/hooks/useUserSubscriptions.ts | 6 +- turbo.json | 4 + yarn.lock | 81 +- 185 files changed, 4599 insertions(+), 3931 deletions(-) create mode 100644 .github/actions/build-docker-image-service/action.yml create mode 100644 .github/actions/build-docker-image/action.yml delete mode 100644 apps/meteor/.scripts/start.js rename apps/meteor/app/api/server/v1/{assets.js => assets.ts} (73%) rename apps/meteor/app/api/server/v1/{users.js => users.ts} (66%) rename apps/meteor/app/assets/server/{assets.js => assets.ts} (63%) rename apps/meteor/app/assets/server/{index.js => index.ts} (100%) delete mode 100644 apps/meteor/app/favico/client/favico.js delete mode 100644 apps/meteor/app/favico/client/index.js delete mode 100644 apps/meteor/app/favico/index.js rename apps/meteor/app/lib/server/functions/{getFullUserData.js => getFullUserData.ts} (65%) delete mode 100644 apps/meteor/app/livechat/client/lib/chartHandler.js create mode 100644 apps/meteor/app/livechat/client/lib/chartHandler.ts delete mode 100644 apps/meteor/app/utils/lib/mimeTypes.js create mode 100644 apps/meteor/app/utils/lib/mimeTypes.ts delete mode 100644 apps/meteor/client/components/VerticalBar/VerticalBarActionBack.tsx create mode 100644 apps/meteor/client/components/VerticalBar/VerticalBarBack.tsx delete mode 100644 apps/meteor/client/lib/createValueSubscription.ts rename apps/meteor/client/sidebar/{RoomMenu.js => RoomMenu.tsx} (76%) rename apps/meteor/client/sidebar/search/{Row.js => Row.tsx} (73%) delete mode 100644 apps/meteor/client/sidebar/search/ScrollerWithCustomProps.js create mode 100644 apps/meteor/client/sidebar/search/ScrollerWithCustomProps.tsx rename apps/meteor/client/sidebar/search/{SearchList.js => SearchList.tsx} (60%) rename apps/meteor/client/sidebar/search/{UserItem.js => UserItem.tsx} (59%) rename apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/{AddUsers.js => AddUsers.tsx} (61%) rename apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/{AddUsersWithData.js => AddUsersWithData.tsx} (67%) create mode 100644 apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts delete mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions.js create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAudioCallAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeLeaderAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeModeratorAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeOwnerAction.tsx create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useDirectMessageAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useIgnoreUserAction.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useMuteUserAction.tsx create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useRemoveUserAction.tsx create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts create mode 100644 apps/meteor/client/views/room/lib/getRoomDirectives.ts delete mode 100644 apps/meteor/client/views/teams/CreateTeamModal/UsersInput.tsx create mode 100644 apps/meteor/client/views/teams/CreateTeamModal/useCreateTeamModalState.ts rename apps/meteor/client/views/teams/{index.js => index.ts} (100%) create mode 100644 apps/meteor/definition/externals/meteor/webapp-hashing.d.ts create mode 100644 apps/meteor/tests/unit/lib/mimeTypes.tests.ts create mode 100644 packages/core-typings/src/IRocketChatAssets.ts create mode 100644 packages/favicon/.eslintrc create mode 100644 packages/favicon/package.json create mode 100644 packages/favicon/src/badge.ts create mode 100644 packages/favicon/src/index.ts create mode 100644 packages/favicon/tsconfig.json create mode 100644 packages/rest-typings/src/v1/assets.ts create mode 100644 packages/rest-typings/src/v1/users/UserCreateParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserDeactivateIdleParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserLogoutParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserRegisterParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UserSetActiveStatusParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersAutocompleteParamsGET.ts create mode 100644 packages/rest-typings/src/v1/users/UsersInfoParamsGet.ts create mode 100644 packages/rest-typings/src/v1/users/UsersListTeamsParamsGET.ts create mode 100644 packages/rest-typings/src/v1/users/UsersSetAvatarParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersSetPreferenceParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersUpdateOwnBasicInfoParamsPOST.ts create mode 100644 packages/rest-typings/src/v1/users/UsersUpdateParamsPOST.ts diff --git a/.github/actions/build-docker-image-service/action.yml b/.github/actions/build-docker-image-service/action.yml new file mode 100644 index 000000000000..aba20175b940 --- /dev/null +++ b/.github/actions/build-docker-image-service/action.yml @@ -0,0 +1,69 @@ +name: 'Build Micro Services Docker image' +description: 'Build Rocket.Chat Micro Services Docker images' + +inputs: + docker-tag: + required: true + service: + required: true + username: + required: false + password: + required: false + +outputs: + image-name: + value: ${{ steps.build-image.outputs.image-name }} + +runs: + using: "composite" + steps: + # - shell: bash + # name: Free disk space + # run: | + # sudo swapoff -a + # sudo rm -f /swapfile + # sudo apt clean + # docker rmi $(docker image ls -aq) + # df -h + + - shell: bash + id: build-image + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + IMAGE_TAG="${{ inputs.docker-tag }}" + + IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/${{ inputs.service }}-service:${IMAGE_TAG}" + + echo "Building Docker image for service: ${{ inputs.service }}:${IMAGE_TAG}" + + if [[ "${{ inputs.service }}" == "ddp-streamer" ]]; then + DOCKERFILE_PATH="./ee/apps/ddp-streamer/Dockerfile" + else + DOCKERFILE_PATH="./apps/meteor/ee/server/services/Dockerfile" + fi + + docker build \ + --build-arg SERVICE=${{ inputs.service }} \ + -t ${IMAGE_NAME} \ + -f ${DOCKERFILE_PATH} \ + . + + echo "::set-output name=image-name::${IMAGE_NAME}" + + - name: Login to GitHub Container Registry + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ inputs.username }} + password: ${{ inputs.password }} + + - name: Publish image + shell: bash + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + run: | + echo "Push Docker image: ${{ steps.build-image.outputs.image-name }}" + + docker push ${{ steps.build-image.outputs.image-name }} diff --git a/.github/actions/build-docker-image/action.yml b/.github/actions/build-docker-image/action.yml new file mode 100644 index 000000000000..240c502efd1c --- /dev/null +++ b/.github/actions/build-docker-image/action.yml @@ -0,0 +1,88 @@ +name: 'Build Docker image' +description: 'Build Rocket.Chat Docker image' + +inputs: + root-dir: + required: true + docker-tag: + required: true + release: + required: true + username: + required: false + password: + required: false + +outputs: + image-name: + value: ${{ steps.build-image.outputs.image-name }} + +runs: + using: composite + steps: + # - shell: bash + # name: Free disk space + # run: | + # sudo swapoff -a + # sudo rm -f /swapfile + # sudo apt clean + # docker rmi $(docker image ls -aq) + # df -h + + - shell: bash + id: build-image + run: | + cd ${{ inputs.root-dir }} + + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + IMAGE_NAME_BASE="ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${{ inputs.docker-tag }}" + + IMAGE_NAME="${IMAGE_NAME_BASE}.${{ inputs.release }}" + + echo "Build Docker image ${IMAGE_NAME}" + + DOCKER_PATH="${GITHUB_WORKSPACE}/apps/meteor/.docker" + if [[ '${{ inputs.release }}' = 'preview' ]]; then + DOCKER_PATH="${DOCKER_PATH}-mongo" + fi; + + DOCKERFILE_PATH="${DOCKER_PATH}/Dockerfile" + if [[ '${{ inputs.release }}' = 'alpine' ]]; then + DOCKERFILE_PATH="${DOCKERFILE_PATH}.${{ inputs.release }}" + fi; + + echo "Copy Dockerfile for release: ${{ inputs.release }}" + cp $DOCKERFILE_PATH ./Dockerfile + if [ -e ${DOCKER_PATH}/entrypoint.sh ]; then + cp ${DOCKER_PATH}/entrypoint.sh . + fi; + + echo "Build ${{ inputs.release }} Docker image" + docker build -t $IMAGE_NAME . + + echo "::set-output name=image-name-base::${IMAGE_NAME_BASE}" + echo "::set-output name=image-name::${IMAGE_NAME}" + + - name: Login to GitHub Container Registry + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ inputs.username }} + password: ${{ inputs.password }} + + - name: Publish image + shell: bash + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'release' || github.ref == 'refs/heads/develop' + run: | + echo "Push Docker image: ${{ steps.build-image.outputs.image-name }}" + + docker push ${{ steps.build-image.outputs.image-name }} + + if [[ '${{ inputs.release }}' = 'official' ]]; then + echo "Push release official without variant" + + docker tag ${{ steps.build-image.outputs.image-name }} ${{ steps.build-image.outputs.image-name-base }} + docker push ${{ steps.build-image.outputs.image-name-base }} + fi; diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index ecfb34c5b1e8..e62ee8217486 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -15,10 +15,50 @@ concurrency: env: CI: true - MONGO_URL: mongodb://localhost:27017 + MONGO_URL: mongodb://localhost:27017/rocketchat + MONGO_OPLOG_URL: mongodb://mongo:27017/local TOOL_NODE_FLAGS: --max_old_space_size=4096 + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} jobs: + release-versions: + runs-on: ubuntu-latest + outputs: + release: ${{ steps.by-tag.outputs.release }} + latest-release: ${{ steps.latest.outputs.latest-release }} + docker-tag: ${{ steps.docker.outputs.docker-tag }} + gh-docker-tag: ${{ steps.docker.outputs.gh-docker-tag }} + steps: + - id: by-tag + run: | + if echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then + RELEASE="latest" + elif echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' ; then + RELEASE="release-candidate" + fi + echo "RELEASE: ${RELEASE}" + echo "::set-output name=release::${RELEASE}" + + - id: latest + run: | + LATEST_RELEASE="$( + git -c 'versionsort.suffix=-' ls-remote -t --exit-code --refs --sort=-v:refname "https://github.com/$GITHUB_REPOSITORY" '*' | + sed -En '1!q;s/^[[:xdigit:]]+[[:space:]]+refs\/tags\/(.+)/\1/gp' + )" + echo "LATEST_RELEASE: ${LATEST_RELEASE}" + echo "::set-output name=latest-release::${LATEST_RELEASE}" + + - id: docker + run: | + if [[ '${{ github.event_name }}' == 'pull_request' ]]; then + DOCKER_TAG="pr-${{ github.event.number }}" + else + DOCKER_TAG="gh-${{ github.run_id }}" + fi + echo "DOCKER_TAG: ${DOCKER_TAG}" + echo "::set-output name=gh-docker-tag::${DOCKER_TAG}" + build: runs-on: ubuntu-20.04 @@ -33,12 +73,13 @@ jobs: echo "github.event_name: ${{ github.event_name }}" cat $GITHUB_EVENT_PATH + - uses: actions/checkout@v3 + - name: Use Node.js 14.18.3 uses: actions/setup-node@v3 with: node-version: '14.18.3' - - - uses: actions/checkout@v3 + cache: 'yarn' - name: Free disk space run: | @@ -48,40 +89,22 @@ jobs: docker rmi $(docker image ls -aq) df -h - # TODO is this required? - # - name: check package-lock - # run: | - # npx package-lock-check - - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 - with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- - - # TODO change to use turbo cache - name: Cache meteor local uses: actions/cache@v2 with: path: ./apps/meteor/.meteor/local - key: ${{ runner.OS }}-meteor_cache-${{ hashFiles('apps/meteor/.meteor/versions') }} + key: meteor-local-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/versions') }} restore-keys: | - ${{ runner.os }}-meteor_cache- - ${{ runner.os }}- + meteor-local-cache-${{ runner.os }}- + - name: Cache meteor uses: actions/cache@v2 with: path: ~/.meteor - key: ${{ runner.OS }}-meteor-${{ hashFiles('apps/meteor/.meteor/release') }} + key: meteor-cache-${{ runner.OS }}-${{ hashFiles('apps/meteor/.meteor/release') }} restore-keys: | - ${{ runner.os }}-meteor- - ${{ runner.os }}- + meteor-cache-${{ runner.os }}- + - name: Install Meteor run: | # Restore bin from cache @@ -114,13 +137,20 @@ jobs: - name: yarn install run: yarn - - run: yarn lint + - name: TurboRepo local server + uses: felixmosh/turborepo-gh-artifacts@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + server-token: ${{ secrets.TURBO_SERVER_TOKEN }} + + - name: Lint + run: yarn lint --api="http://127.0.0.1:9080" - - run: yarn turbo run translation-check + - name: Translation check + run: yarn turbo run translation-check --api="http://127.0.0.1:9080" - name: TS typecheck - run: | - yarn turbo run typecheck + run: yarn turbo run typecheck --api="http://127.0.0.1:9080" - name: Reset Meteor if: startsWith(github.ref, 'refs/tags/') == 'true' || github.ref == 'refs/heads/develop' @@ -132,39 +162,61 @@ jobs: if: startsWith(github.ref, 'refs/pull/') == true env: METEOR_PROFILE: 1000 - run: | - yarn build:ci -- --debug --directory /tmp/build-test + run: yarn build:ci --api="http://127.0.0.1:9080" -- --debug --directory dist - name: Build Rocket.Chat if: startsWith(github.ref, 'refs/pull/') != true - run: | - yarn build:ci -- --directory /tmp/build-test + run: yarn build:ci --api="http://127.0.0.1:9080" -- --directory dist - name: Prepare build run: | - mkdir /tmp/build/ - cd /tmp/build-test - tar czf /tmp/build/Rocket.Chat.tar.gz bundle - cd /tmp/build-test/bundle/programs/server - npm install --production - cd /tmp - tar czf Rocket.Chat.test.tar.gz ./build-test - - - name: Store build for tests - uses: actions/upload-artifact@v2 - with: - name: build-test - path: /tmp/Rocket.Chat.test.tar.gz + cd apps/meteor/dist + tar czf /tmp/Rocket.Chat.tar.gz bundle - name: Store build uses: actions/upload-artifact@v2 + with: + name: build + path: /tmp/Rocket.Chat.tar.gz + + build-docker-preview: + runs-on: ubuntu-20.04 + needs: [build, release-versions] + if: github.event_name == 'release' || github.ref == 'refs/heads/develop' + steps: + - uses: actions/checkout@v3 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'yarn' + + - name: Restore build + uses: actions/download-artifact@v2 with: name: build path: /tmp/build + - name: Unpack build + run: | + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz + + - name: Build Docker image + id: build-docker-image-preview + uses: ./.github/actions/build-docker-image + with: + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: preview + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + test: runs-on: ubuntu-20.04 - needs: build + needs: [build, release-versions] strategy: matrix: @@ -177,79 +229,142 @@ jobs: with: mongoDBVersion: ${{ matrix.mongodb-version }} --replSet=rs0 - - name: Restore build for tests - uses: actions/download-artifact@v2 - with: - name: build-test - path: /tmp - - - name: Decompress build - run: | - cd /tmp - tar xzf Rocket.Chat.test.tar.gz - cd - + - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} + cache: 'yarn' - name: Setup Chrome - run: | - npm i chromedriver + run: npm i chromedriver - name: Configure Replica Set run: | docker exec mongo mongo --eval 'rs.initiate({_id:"rs0", members: [{"_id":1, "host":"localhost:27017"}]})' docker exec mongo mongo --eval 'rs.status()' - - uses: actions/checkout@v3 + - name: yarn install + run: yarn - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 + - name: TurboRepo local server + uses: felixmosh/turborepo-gh-artifacts@v1 with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- - - - name: Yarn install - # if: steps.cache-nodemodules.outputs.cache-hit != 'true' || steps.cache-cypress.outputs.cache-hit != 'true' - run: yarn + repo-token: ${{ secrets.GITHUB_TOKEN }} + server-token: ${{ secrets.TURBO_SERVER_TOKEN }} - name: Unit Test - run: yarn testunit + run: yarn testunit --api="http://127.0.0.1:9080" - - name: Install Playwright + - name: Restore build + uses: actions/download-artifact@v2 + with: + name: build + path: /tmp/build + + - name: Unpack build run: | - cd ./apps/meteor - npx playwright install --with-deps + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz + + - name: Build Docker image + id: build-docker-image + if: matrix.mongodb-version != '5.0' + uses: ./.github/actions/build-docker-image + with: + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: official + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: Build Alpine Docker image + id: build-docker-image-alpine + if: matrix.mongodb-version == '5.0' + uses: ./.github/actions/build-docker-image + with: + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: alpine + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + # TODO move startup/restart to its own github action + - name: Start up Rocket.Chat + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + # test alpine image on mongo 5.0 (no special reason to be mongo 5.0 but we need to test alpine at least once) + if [[ '${{ matrix.mongodb-version }}' = '5.0' ]]; then + IMAGE_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}.alpine" + else + IMAGE_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}.official" + fi; + + IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${IMAGE_TAG}" + + docker run --name rocketchat -d \ + --link mongo \ + -p 3000:3000 \ + -e TEST_MODE=true \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + ${IMAGE_NAME} - name: E2E Test API - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local run: | + docker logs rocketchat --tail=50 + cd ./apps/meteor - echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc - Xvfb -screen 0 1024x768x24 :99 & - for i in $(seq 1 5); do (docker exec mongo mongo rocketchat --eval 'db.dropDatabase()') && npm run testci -- --test=testapi && s=0 && break || s=$? && sleep 1; done; (exit $s) + for i in $(seq 1 5); do + docker stop rocketchat + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' - - name: E2E Test UI - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW + + docker start rocketchat + + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + npm run testapi && s=0 && break || s=$? && docker logs rocketchat --tail=100; + done; + exit $s + + - name: Install Playwright run: | cd ./apps/meteor + npx playwright install --with-deps + + - name: E2E Test UI + run: | echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc Xvfb -screen 0 1024x768x24 :99 & - docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' && npm run testci -- --test=test:playwright + + docker logs rocketchat --tail=50 + + docker stop rocketchat + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' + + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW + + docker start rocketchat + + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + cd ./apps/meteor + npm run test:playwright - name: Store playwright test trace uses: actions/upload-artifact@v2 @@ -260,7 +375,7 @@ jobs: test-ee: runs-on: ubuntu-20.04 - needs: build + needs: [build, release-versions] strategy: matrix: @@ -276,311 +391,304 @@ jobs: - name: Launch NATS run: sudo docker run --name nats -d -p 4222:4222 nats:2.4 - - name: Restore build for tests - uses: actions/download-artifact@v2 - with: - name: build-test - path: /tmp - - - name: Decompress build - run: | - cd /tmp - tar xzf Rocket.Chat.test.tar.gz - cd - + - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} + cache: 'yarn' - name: Setup Chrome - run: | - npm i chromedriver + run: npm i chromedriver - name: Configure Replica Set run: | docker exec mongo mongo --eval 'rs.initiate({_id:"rs0", members: [{"_id":1, "host":"localhost:27017"}]})' docker exec mongo mongo --eval 'rs.status()' - - uses: actions/checkout@v3 - - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 + - name: TurboRepo local server + uses: felixmosh/turborepo-gh-artifacts@v1 with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- + repo-token: ${{ secrets.GITHUB_TOKEN }} + server-token: ${{ secrets.TURBO_SERVER_TOKEN }} - - name: Yarn install + - name: yarn install run: yarn - - name: Build micro services - run: yarn build - - - name: E2E Test API - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local - ENTERPRISE_LICENSE: ${{ secrets.ENTERPRISE_LICENSE }} - TRANSPORTER: nats://localhost:4222 - SKIP_PROCESS_EVENT_REGISTRATION: 'true' - run: | - echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc - Xvfb -screen 0 1024x768x24 :99 & - - cd ./apps/meteor/ + - name: Unit Test + run: yarn testunit --api="http://127.0.0.1:9080" - for i in $(seq 1 5); do (docker exec mongo mongo rocketchat --eval 'db.dropDatabase()') && npm run testci -- --enterprise --test=testapi && s=0 && break || s=$? && sleep 1; done; (exit $s) + - name: Restore build + uses: actions/download-artifact@v2 + with: + name: build + path: /tmp/build - - name: Install Playwright + - name: Unpack build run: | - cd ./apps/meteor/ - npx playwright install --with-deps - - - name: E2E Test UI - env: - TEST_MODE: 'true' - MONGO_URL: mongodb://localhost:27017/rocketchat - MONGO_OPLOG_URL: mongodb://localhost:27017/local - ENTERPRISE_LICENSE: ${{ secrets.ENTERPRISE_LICENSE }} - TRANSPORTER: nats://localhost:4222 - TEST_API_URL: http://localhost:4000 - OVERWRITE_SETTING_Site_Url: http://localhost:4000 - SKIP_PROCESS_EVENT_REGISTRATION: 'true' - run: | - echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc - Xvfb -screen 0 1024x768x24 :99 & - - cd ./apps/meteor - - docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' && npm run testci -- --enterprise --test=test:playwright:ee + cd /tmp/build + tar xzf Rocket.Chat.tar.gz + rm Rocket.Chat.tar.gz - - name: Store playwright test trace - uses: actions/upload-artifact@v2 - if: failure() + - name: Build Docker image + id: build-docker-image + uses: ./.github/actions/build-docker-image with: - name: ee-playwright-test-trace - path: ./apps/meteor/tests/e2e/test-failures* - - # notification: - # runs-on: ubuntu-20.04 - # needs: test - - # steps: - # - name: Rocket.Chat Notification - # uses: RocketChat/Rocket.Chat.GitHub.Action.Notification@1.1.1 - # with: - # type: ${{ job.status }} - # job_name: '**Build and Test**' - # url: ${{ secrets.ROCKETCHAT_WEBHOOK }} - # commit: true - # token: ${{ secrets.GITHUB_TOKEN }} - - build-image-pr: - runs-on: ubuntu-20.04 - if: github.event.pull_request.head.repo.full_name == github.repository - - strategy: - matrix: - release: ['official', 'preview'] - - steps: - - uses: actions/checkout@v3 + root-dir: /tmp/build + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + release: official + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Login to GitHub Container Registry - uses: docker/login-action@v1 + - name: 'Build Docker image: account' + uses: ./.github/actions/build-docker-image-service with: - registry: ghcr.io + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: account username: ${{ secrets.CR_USER }} password: ${{ secrets.CR_PAT }} - - name: Free disk space - run: | - sudo swapoff -a - sudo rm -f /swapfile - sudo apt clean - docker rmi $(docker image ls -aq) - df -h - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 + - name: 'Build Docker image: authorization' + uses: ./.github/actions/build-docker-image-service with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: authorization + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Cache meteor local - uses: actions/cache@v2 + - name: 'Build Docker image: ddp-streamer' + uses: ./.github/actions/build-docker-image-service with: - path: ./apps/meteor/.meteor/local - key: ${{ runner.OS }}-meteor_cache-${{ hashFiles('.meteor/versions') }} - restore-keys: | - ${{ runner.os }}-meteor_cache- - ${{ runner.os }}- - - name: Cache meteor - uses: actions/cache@v2 + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: ddp-streamer + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: 'Build Docker image: presence' + uses: ./.github/actions/build-docker-image-service with: - path: ~/.meteor - key: ${{ runner.OS }}-meteor-${{ hashFiles('.meteor/release') }} - restore-keys: | - ${{ runner.os }}-meteor- - ${{ runner.os }}- - - name: Use Node.js 14.18.3 - uses: actions/setup-node@v3 + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: presence + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: 'Build Docker image: stream-hub' + uses: ./.github/actions/build-docker-image-service with: - node-version: '14.18.3' + docker-tag: ${{ needs.release-versions.outputs.gh-docker-tag }} + service: stream-hub + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Install Meteor + - name: Launch Traefik run: | - # Restore bin from cache - set +e - METEOR_SYMLINK_TARGET=$(readlink ~/.meteor/meteor) - METEOR_TOOL_DIRECTORY=$(dirname "$METEOR_SYMLINK_TARGET") - set -e - LAUNCHER=$HOME/.meteor/$METEOR_TOOL_DIRECTORY/scripts/admin/launch-meteor - if [ -e $LAUNCHER ] - then - echo "Cached Meteor bin found, restoring it" - sudo cp "$LAUNCHER" "/usr/local/bin/meteor" - else - echo "No cached Meteor bin found." - fi - - # only install meteor if bin isn't found - command -v meteor >/dev/null 2>&1 || curl https://install.meteor.com | sed s/--progress-bar/-sL/g | /bin/sh + docker run --name traefik -d \ + -p 3000:80 \ + -v /var/run/docker.sock:/var/run/docker.sock \ + traefik:2.7 \ + --providers.docker=true - - name: Versions + # TODO move startup/restart to its own github action + - name: Start up Rocket.Chat run: | - npm --versions - yarn -v - node -v - meteor --version - meteor npm --versions - meteor node -v - git version + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - - name: Yarn install - # if: steps.cache-nodemodules.outputs.cache-hit != 'true' - run: yarn + docker run --name rocketchat -d \ + --link mongo \ + --link nats \ + -e TEST_MODE=true \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + -e ENTERPRISE_LICENSE="${{ secrets.ENTERPRISE_LICENSE }}" \ + -e SKIP_PROCESS_EVENT_REGISTRATION=true \ + --label 'traefik.http.routers.rocketchat.rule=PathPrefix(`/`)' \ + ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${{ needs.release-versions.outputs.gh-docker-tag }}.official + + # spin up all micro services + docker run --name ddp-streamer -d \ + --link mongo \ + --link nats \ + -e PORT=4000 \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + --label 'traefik.http.services.ddp-streamer.loadbalancer.server.port=4000' \ + --label 'traefik.http.routers.ddp-streamer.rule=PathPrefix(`/websocket`) || PathPrefix(`/sockjs`)' \ + ghcr.io/${LOWERCASE_REPOSITORY}/ddp-streamer-service:${{ needs.release-versions.outputs.gh-docker-tag }} + + - name: 'Start service: stream-hub' + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - # To reduce memory need during actual build, build the packages solely first - # - name: Build a Meteor cache - # run: | - # # to do this we can clear the main files and it build the rest - # echo "" > server/main.ts - # echo "" > client/main.ts - # sed -i.backup 's/rocketchat:livechat/#rocketchat:livechat/' .meteor/packages - # meteor build --server-only --debug --directory /tmp/build-temp - # git checkout -- server/main.ts client/main.ts .meteor/packages + docker run --name stream-hub -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/stream-hub-service:${{ needs.release-versions.outputs.gh-docker-tag }} - - name: Build Rocket.Chat - run: yarn build:ci -- --directory /tmp/build-pr + until echo "$(docker logs stream-hub)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'stream-hub' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - - name: Build Docker image for PRs + - name: 'Start service: account' run: | - cd /tmp/build-pr + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") + + docker run --name account -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/account-service:${{ needs.release-versions.outputs.gh-docker-tag }} + until echo "$(docker logs account)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'account' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + - name: 'Start service: authorization' + run: | LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - IMAGE_NAME="rocket.chat" - if [[ '${{ matrix.release }}' = 'preview' ]]; then - IMAGE_NAME="${IMAGE_NAME}.preview" - fi; - IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/${IMAGE_NAME}:pr-${{ github.event.number }}" + docker run --name authorization -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/authorization-service:${{ needs.release-versions.outputs.gh-docker-tag }} - echo "Build official Docker image ${IMAGE_NAME}" + until echo "$(docker logs authorization)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'authorization' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - DOCKER_PATH="${GITHUB_WORKSPACE}/apps/meteor/.docker" - if [[ '${{ matrix.release }}' = 'preview' ]]; then - DOCKER_PATH="${DOCKER_PATH}-mongo" - fi; + - name: 'Start service: presence' + run: | + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - echo "Build ${{ matrix.release }} Docker image" - cp ${DOCKER_PATH}/Dockerfile . - if [ -e ${DOCKER_PATH}/entrypoint.sh ]; then - cp ${DOCKER_PATH}/entrypoint.sh . - fi; + docker run --name presence -d \ + --link mongo \ + --link nats \ + -e MONGO_URL=mongodb://mongo:27017/rocketchat \ + -e MONGO_OPLOG_URL=mongodb://mongo:27017/local \ + -e TRANSPORTER=nats://nats:4222 \ + -e MOLECULER_LOG_LEVEL=info \ + ghcr.io/${LOWERCASE_REPOSITORY}/presence-service:${{ needs.release-versions.outputs.gh-docker-tag }} + + until echo "$(docker logs presence)" | grep -q "NetworkBroker started successfully"; do + echo "Waiting 'presence' to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - docker build -t $IMAGE_NAME . - docker push $IMAGE_NAME + - name: E2E Test API + run: | + cd ./apps/meteor + for i in $(seq 1 5); do + docker stop rocketchat + docker stop stream-hub + docker stop account + docker stop authorization + docker stop ddp-streamer + docker stop presence + + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' + + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW + + docker start rocketchat + docker start stream-hub + docker start account + docker start authorization + docker start ddp-streamer + docker start presence + + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done + + docker logs rocketchat --tail=50 + docker logs stream-hub --tail=50 + docker logs account --tail=50 + docker logs authorization --tail=50 + docker logs ddp-streamer --tail=50 + docker logs presence --tail=50 + + npm run testapi && s=0 && break || s=$? && docker logs rocketchat --tail=100 && docker logs authorization --tail=50; + done; + exit $s - services-image-build-check: - runs-on: ubuntu-20.04 - if: github.event.pull_request.head.repo.full_name == github.repository + - name: Install Playwright + run: | + cd ./apps/meteor + npx playwright install --with-deps - strategy: - matrix: - service: ['ddp-streamer'] + - name: E2E Test UI + run: | + echo -e 'pcm.!default {\n type hw\n card 0\n}\n\nctl.!default {\n type hw\n card 0\n}' > ~/.asoundrc + Xvfb -screen 0 1024x768x24 :99 & - steps: - - uses: actions/checkout@v3 + docker logs rocketchat --tail=50 - - name: Use Node.js 14.18.3 - uses: actions/setup-node@v3 - with: - node-version: '14.18.3' + docker stop rocketchat + docker stop stream-hub + docker stop account + docker stop authorization + docker stop ddp-streamer + docker stop presence - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 - with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- + docker exec mongo mongo rocketchat --eval 'db.dropDatabase()' - - name: Build Docker images - env: - IMAGE_TAG: check - run: | - yarn - yarn build + NOW=$(date "+%Y-%m-%dT%H:%M:%SZ") + echo $NOW - echo "Building Docker image for service: ${{ matrix.service }}:${IMAGE_TAG}" + docker start rocketchat + docker start stream-hub + docker start account + docker start authorization + docker start ddp-streamer + docker start presence - docker build \ - --build-arg SERVICE=${{ matrix.service }} \ - -t rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} \ - -f ./ee/apps/ddp-streamer/Dockerfile \ - . + until echo "$(docker logs rocketchat --since $NOW)" | grep -q "SERVER RUNNING"; do + echo "Waiting Rocket.Chat to start up" + ((c++)) && ((c==10)) && exit 1 + sleep 10 + done - release-versions: - runs-on: ubuntu-latest - outputs: - release: ${{ steps.by-tag.outputs.release }} - latest-release: ${{ steps.latest.outputs.latest-release }} - steps: - - id: by-tag - run: | - if echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' ; then - RELEASE="latest" - elif echo "$GITHUB_REF_NAME" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' ; then - RELEASE="release-candidate" - fi - echo "RELEASE: ${RELEASE}" - echo "::set-output name=release::${RELEASE}" + docker logs rocketchat --tail=50 + docker logs stream-hub --tail=50 + docker logs account --tail=50 + docker logs authorization --tail=50 + docker logs ddp-streamer --tail=50 + docker logs presence --tail=50 - - id: latest - run: | - LATEST_RELEASE="$( - git -c 'versionsort.suffix=-' ls-remote -t --exit-code --refs --sort=-v:refname "https://github.com/$GITHUB_REPOSITORY" '*' | - sed -En '1!q;s/^[[:xdigit:]]+[[:space:]]+refs\/tags\/(.+)/\1/gp' - )" - echo "LATEST_RELEASE: ${LATEST_RELEASE}" - echo "::set-output name=latest-release::${LATEST_RELEASE}" + cd ./apps/meteor + npm run test:playwright + + - name: Store playwright test trace + uses: actions/upload-artifact@v2 + if: failure() + with: + name: playwright-test-trace + path: ./apps/meteor/tests/e2e/test-failures* deploy: runs-on: ubuntu-20.04 @@ -661,114 +769,88 @@ jobs: -d '{"tag":"'$GIT_TAG'"}' fi - image-build: + docker-image-publish: runs-on: ubuntu-20.04 - needs: [deploy, release-versions] + needs: [deploy, build-docker-preview, release-versions] strategy: matrix: - # this is current a mix of variants and different images + # this is currently a mix of variants and different images release: ['official', 'preview', 'alpine'] env: IMAGE_NAME: 'rocketchat/rocket.chat' steps: - - uses: actions/checkout@v3 - - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - - name: Restore build - uses: actions/download-artifact@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 with: - name: build - path: /tmp/build + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} - - name: Unpack build and prepare Docker files + - name: Get Docker image name + id: gh-docker run: | - cd /tmp/build - tar xzf Rocket.Chat.tar.gz - rm Rocket.Chat.tar.gz - - DOCKER_PATH="${GITHUB_WORKSPACE}/apps/meteor/.docker" - if [[ '${{ matrix.release }}' = 'preview' ]]; then - DOCKER_PATH="${DOCKER_PATH}-mongo" - fi; + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - DOCKERFILE_PATH="${DOCKER_PATH}/Dockerfile" - if [[ '${{ matrix.release }}' = 'alpine' ]]; then - DOCKERFILE_PATH="${DOCKERFILE_PATH}.${{ matrix.release }}" - fi; + GH_IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/rocket.chat:${{ needs.release-versions.outputs.gh-docker-tag }}.${{ matrix.release }}" - echo "Copy Dockerfile for release: ${{ matrix.release }}" - cp $DOCKERFILE_PATH ./Dockerfile - if [ -e ${DOCKER_PATH}/entrypoint.sh ]; then - cp ${DOCKER_PATH}/entrypoint.sh . - fi; + echo "GH_IMAGE_NAME: $GH_IMAGE_NAME" - - name: Build Docker image for tag - if: github.event_name == 'release' - run: | - cd /tmp/build + echo "::set-output name=gh-image-name::${GH_IMAGE_NAME}" - DOCKER_TAG=$GITHUB_REF_NAME + - name: Pull Docker image + run: docker pull ${{ steps.gh-docker.outputs.gh-image-name }} + - name: Publish Docker image + run: | if [[ '${{ matrix.release }}' = 'preview' ]]; then IMAGE_NAME="${IMAGE_NAME}.preview" fi; + # 'develop' or 'tag' + DOCKER_TAG=$GITHUB_REF_NAME + # append the variant name to docker tag if [[ '${{ matrix.release }}' = 'alpine' ]]; then DOCKER_TAG="${DOCKER_TAG}-${{ matrix.release }}" fi; - RELEASE="${{ needs.release-versions.outputs.release }}" - - if [[ '${{ matrix.release }}' = 'alpine' ]]; then - RELEASE="${RELEASE}-${{ matrix.release }}" - fi; - echo "IMAGE_NAME: $IMAGE_NAME" echo "DOCKER_TAG: $DOCKER_TAG" - echo "RELEASE: $RELEASE" - # build and push the specific tag version - docker build -t $IMAGE_NAME:$DOCKER_TAG . + # tag and push the specific tag version + docker tag ${{ steps.gh-docker.outputs.gh-image-name }} $IMAGE_NAME:$DOCKER_TAG docker push $IMAGE_NAME:$DOCKER_TAG - if [[ $RELEASE == 'latest' ]]; then - if [[ '${{ needs.release-versions.outputs.latest-release }}' == $GITHUB_REF_NAME ]]; then - docker tag $IMAGE_NAME:$DOCKER_TAG $IMAGE_NAME:$RELEASE - docker push $IMAGE_NAME:$RELEASE - fi - else - docker tag $IMAGE_NAME:$DOCKER_TAG $IMAGE_NAME:$RELEASE - docker push $IMAGE_NAME:$RELEASE - fi - - - name: Build Docker image for develop - if: github.ref == 'refs/heads/develop' - run: | - cd /tmp/build + if [[ $GITHUB_REF == refs/tags/* ]]; then + RELEASE="${{ needs.release-versions.outputs.release }}" - DOCKER_TAG=develop + if [[ '${{ matrix.release }}' = 'alpine' ]]; then + RELEASE="${RELEASE}-${{ matrix.release }}" + fi; - if [[ '${{ matrix.release }}' = 'preview' ]]; then - IMAGE_NAME="${IMAGE_NAME}.preview" - fi; + echo "RELEASE: $RELEASE" - if [[ '${{ matrix.release }}' = 'alpine' ]]; then - DOCKER_TAG="${DOCKER_TAG}-${{ matrix.release }}" - fi; - - docker build -t $IMAGE_NAME:$DOCKER_TAG . - docker push $IMAGE_NAME:$DOCKER_TAG + if [[ $RELEASE == 'latest' ]]; then + if [[ '${{ needs.release-versions.outputs.latest-release }}' == $GITHUB_REF_NAME ]]; then + docker tag ${{ steps.gh-docker.outputs.gh-image-name }} $IMAGE_NAME:$RELEASE + docker push $IMAGE_NAME:$RELEASE + fi + else + docker tag ${{ steps.gh-docker.outputs.gh-image-name }} $IMAGE_NAME:$RELEASE + docker push $IMAGE_NAME:$RELEASE + fi + fi - services-image-build: + services-docker-image-publish: runs-on: ubuntu-20.04 needs: [deploy, release-versions] @@ -777,57 +859,35 @@ jobs: service: ['account', 'authorization', 'ddp-streamer', 'presence', 'stream-hub'] steps: - - uses: actions/checkout@v3 - - - name: Use Node.js 14.18.3 - uses: actions/setup-node@v3 - with: - node-version: '14.18.3' - - uses: c-hive/gha-yarn-cache@v2 - - name: Cache turbo - id: cache-turbo - uses: actions/cache@v2 - with: - path: | - ./node_modules/.turbo - key: ${{ runner.OS }}-turbo-${{ hashFiles('**/yarn.lock') }} - restore-keys: | - ${{ runner.os }}-turbo- - ${{ runner.os }}- - - name: Login to DockerHub - uses: docker/login-action@v1 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - - name: Build Docker images + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ secrets.CR_USER }} + password: ${{ secrets.CR_PAT }} + + - name: Publish Docker images run: | - # defines image tag - if [[ $GITHUB_REF == refs/tags/* ]]; then - IMAGE_TAG="${GITHUB_REF#refs/tags/}" - else - IMAGE_TAG="${GITHUB_REF#refs/heads/}" - fi + LOWERCASE_REPOSITORY=$(echo "${{ github.repository_owner }}" | tr "[:upper:]" "[:lower:]") - # first install repo dependencies - yarn - yarn build + IMAGE_TAG="${{ needs.release-versions.outputs.gh-docker-tag }}" - echo "Building Docker image for service: ${{ matrix.service }}:${IMAGE_TAG}" + GH_IMAGE_NAME="ghcr.io/${LOWERCASE_REPOSITORY}/${{ matrix.service }}-service:${IMAGE_TAG}" - if [[ "${{ matrix.service }}" == "ddp-streamer" ]]; then - DOCKERFILE_PATH="./ee/apps/ddp-streamer/Dockerfile" - else - DOCKERFILE_PATH="./apps/meteor/ee/server/services/Dockerfile" - fi + echo "GH_IMAGE_NAME: $GH_IMAGE_NAME" + + docker pull $GH_IMAGE_NAME - docker build \ - --build-arg SERVICE=${{ matrix.service }} \ - -t rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} \ - -f ${DOCKERFILE_PATH} \ - . + # 'develop' or 'tag' + DOCKER_TAG=$GITHUB_REF_NAME + docker tag $GH_IMAGE_NAME rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} docker push rocketchat/${{ matrix.service }}-service:${IMAGE_TAG} if [[ $GITHUB_REF == refs/tags/* ]]; then diff --git a/.yarnrc.yml b/.yarnrc.yml index 1d00b70c6e57..18948c0be5b9 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -11,4 +11,5 @@ plugins: spec: '@yarnpkg/plugin-typescript' yarnPath: .yarn/releases/yarn-3.2.0.cjs -checksumBehavior: 'ignore' +checksumBehavior: 'update' +enableImmutableInstalls: false diff --git a/_templates/package/new/package.json.ejs.t b/_templates/package/new/package.json.ejs.t index 827f44a01ad6..948e2ded4be4 100644 --- a/_templates/package/new/package.json.ejs.t +++ b/_templates/package/new/package.json.ejs.t @@ -17,7 +17,8 @@ to: packages/<%= name %>/package.json "lint": "eslint --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx . --fix", "jest": "jest", - "build": "rm -rf dist && tsc -p tsconfig.json" + "build": "rm -rf dist && tsc -p tsconfig.json", + "dev": "tsc -p --watch --preserveWatchOutput tsconfig.json" }, "main": "./dist/index.js", "typings": "./dist/index.d.ts", diff --git a/apps/meteor/.docker/Dockerfile.alpine b/apps/meteor/.docker/Dockerfile.alpine index 0c7bd994e8f5..47d5dacd6960 100644 --- a/apps/meteor/.docker/Dockerfile.alpine +++ b/apps/meteor/.docker/Dockerfile.alpine @@ -12,7 +12,7 @@ RUN set -x \ && npm install --production \ # Start hack for sharp... && rm -rf npm/node_modules/sharp \ - && npm install sharp@0.29.3 \ + && npm install sharp@0.30.4 \ && mv node_modules/sharp npm/node_modules/sharp \ # End hack for sharp && cd npm \ diff --git a/apps/meteor/.eslintignore b/apps/meteor/.eslintignore index cb0aed399c3b..9b8e18053149 100644 --- a/apps/meteor/.eslintignore +++ b/apps/meteor/.eslintignore @@ -5,7 +5,6 @@ packages/autoupdate/ packages/meteor-streams/ packages/meteor-timesync/ app/emoji-emojione/generateEmojiIndex.js -app/favico/favico.js packages/rocketchat-livechat/assets/rocketchat-livechat.min.js packages/rocketchat-livechat/assets/rocket-livechat.js app/theme/client/vendor/ diff --git a/apps/meteor/.gitignore b/apps/meteor/.gitignore index 3fc755f6bb5b..e8a861e58324 100644 --- a/apps/meteor/.gitignore +++ b/apps/meteor/.gitignore @@ -84,3 +84,4 @@ coverage /data tests/e2e/test-failures/ out.txt +dist diff --git a/apps/meteor/.meteorignore b/apps/meteor/.meteorignore index 9b77b349e3fd..7b2dc6e71c31 100644 --- a/apps/meteor/.meteorignore +++ b/apps/meteor/.meteorignore @@ -1,3 +1,4 @@ ee/server/services coverage data +dist diff --git a/apps/meteor/.scripts/start.js b/apps/meteor/.scripts/start.js deleted file mode 100644 index a29e7bb1dbf1..000000000000 --- a/apps/meteor/.scripts/start.js +++ /dev/null @@ -1,196 +0,0 @@ -#!/usr/bin/env node - -const path = require('path'); -const fs = require('fs'); -const { spawn } = require('child_process'); -const net = require('net'); - -const processes = []; - -const baseDir = path.resolve(__dirname, '..'); -const srcDir = path.resolve(baseDir); - -const isPortTaken = (port) => - new Promise((resolve, reject) => { - const tester = net - .createServer() - .once('error', (err) => (err.code === 'EADDRINUSE' ? resolve(true) : reject(err))) - .once('listening', () => tester.once('close', () => resolve(false)).close()) - .listen(port); - }); - -const waitPortRelease = (port, count = 0) => - new Promise((resolve, reject) => { - isPortTaken(port).then((taken) => { - if (!taken) { - return resolve(); - } - if (count > 60) { - return reject(); - } - console.log('Port', port, 'not released, waiting 1s...'); - setTimeout(() => { - waitPortRelease(port, ++count) - .then(resolve) - .catch(reject); - }, 1000); - }); - }); - -const appOptions = { - env: { - PORT: 3000, - ROOT_URL: 'http://localhost:3000', - }, -}; - -let killingAllProcess = false; -function killAllProcesses(mainExitCode) { - if (killingAllProcess) { - return; - } - killingAllProcess = true; - - processes.forEach((p) => { - console.log('Killing process', p.pid); - p.kill(); - }); - - waitPortRelease(appOptions.env.PORT) - .then(() => { - console.log(`Port ${appOptions.env.PORT} was released, exiting with code ${mainExitCode}`); - process.exit(mainExitCode); - }) - .catch((error) => { - console.error(`Error waiting port ${appOptions.env.PORT} to be released, exiting with code ${mainExitCode}`); - console.error(error); - process.exit(mainExitCode); - }); -} - -function startProcess(opts) { - console.log('Starting process', opts.name, opts.command, opts.params, opts.options.cwd); - const proc = spawn(opts.command, opts.params, opts.options); - processes.push(proc); - - if (opts.onData) { - proc.stdout.on('data', opts.onData); - } - - if (!opts.silent) { - proc.stdout.pipe(process.stdout); - proc.stderr.pipe(process.stderr); - } - - if (opts.logFile) { - const logStream = fs.createWriteStream(opts.logFile, { flags: 'a' }); - proc.stdout.pipe(logStream); - proc.stderr.pipe(logStream); - } - - proc.on('exit', function (code, signal) { - processes.splice(processes.indexOf(proc), 1); - - if (code != null) { - console.log(opts.name, `exited with code ${code}`); - } else { - console.log(opts.name, `exited with signal ${signal}`); - } - - killAllProcesses(code); - }); -} - -function startRocketChat() { - return new Promise((resolve) => { - const waitServerRunning = (message) => { - if (message.toString().match('SERVER RUNNING')) { - return resolve(); - } - }; - - startProcess({ - name: 'Meteor App', - command: 'node', - params: ['/tmp/build-test/bundle/main.js'], - onData: waitServerRunning, - options: { - cwd: srcDir, - env: { - ...appOptions.env, - ...process.env, - }, - }, - }); - }); -} - -async function startMicroservices() { - const waitStart = (resolve) => (message) => { - if (message.toString().match('NetworkBroker started successfully')) { - return resolve(); - } - }; - const startService = (name) => { - return new Promise((resolve) => { - const cwd = - name === 'ddp-streamer' - ? path.resolve(srcDir, '..', '..', 'ee', 'apps', name, 'dist', 'ee', 'apps', name) - : path.resolve(srcDir, 'ee', 'server', 'services', 'dist', 'ee', 'server', 'services', name); - - startProcess({ - name: `${name} service`, - command: 'node', - params: [name === 'ddp-streamer' ? 'src/service.js' : 'service.js'], - onData: waitStart(resolve), - options: { - cwd, - env: { - ...appOptions.env, - ...process.env, - PORT: 4000, - }, - }, - }); - }); - }; - - await Promise.all([ - startService('account'), - startService('authorization'), - startService('ddp-streamer'), - startService('presence'), - startService('stream-hub'), - ]); -} - -function startTests(options = []) { - const testOption = options.find((i) => i.startsWith('--test=')); - const testParam = testOption ? testOption.replace('--test=', '') : 'test'; - - console.log(`Running test "npm run ${testParam}"`); - - startProcess({ - name: 'Tests', - command: 'npm', - params: ['run', testParam], - options: { - env: { - ...process.env, - NODE_PATH: `${process.env.NODE_PATH + path.delimiter + srcDir + path.delimiter + srcDir}/node_modules`, - }, - }, - }); -} - -(async () => { - const [, , ...options] = process.argv; - - await startRocketChat(); - - if (options.includes('--enterprise')) { - await startMicroservices(); - } - - startTests(options); -})(); diff --git a/apps/meteor/app/api/server/api.d.ts b/apps/meteor/app/api/server/api.d.ts index a1dd63713747..ebc4871610e3 100644 --- a/apps/meteor/app/api/server/api.d.ts +++ b/apps/meteor/app/api/server/api.d.ts @@ -9,7 +9,7 @@ import type { } from '@rocket.chat/rest-typings'; import type { IUser, IMethodConnection, IRoom } from '@rocket.chat/core-typings'; import type { ValidateFunction } from 'ajv'; -import type { Request } from 'express'; +import type { Request, Response } from 'express'; import { ITwoFactorOptions } from '../../2fa/server/code'; @@ -73,11 +73,13 @@ type Options = ( type PartialThis = { readonly request: Request & { query: Record }; + readonly response: Response; }; type ActionThis = { readonly requestIp: string; urlParams: UrlParams; + readonly response: Response; // TODO make it unsafe readonly queryParams: TMethod extends 'GET' ? TOptions extends { validateParams: ValidateFunction } @@ -91,6 +93,9 @@ type ActionThis>; readonly request: Request; + + readonly queryOperations: TOptions extends { queryOperations: infer T } ? T : never; + /* @deprecated */ requestParams(): OperationParams; getLoggedInUser(): TOptions extends { authRequired: true } ? IUser : IUser | undefined; @@ -106,6 +111,8 @@ type ActionThis declare class APIClass { fieldSeparator: string; + updateRateLimiterDictionaryForRoute(route: string, rateLimiterDictionary: number): void; + limitedUserFieldsToExclude(fields: { [x: string]: unknown }, limitedUserFieldsToExclude: unknown): { [x: string]: unknown }; limitedUserFieldsToExcludeIfIsPrivilegedUser( diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index 8ff1737cc692..7762b9b20a18 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -16,7 +16,7 @@ export async function findUsersToAutocomplete({ term: string; }; }): Promise<{ - items: IUser[]; + items: Required>[]; }> { if (!(await hasPermissionAsync(uid, 'view-outside-room'))) { return { items: [] }; @@ -69,16 +69,7 @@ export function getInclusiveFields(query: { [k: string]: 1 }): {} { * get the default fields if **fields** are empty (`{}`) or `undefined`/`null` * @param {Object|null|undefined} fields the fields from parsed jsonQuery */ -export function getNonEmptyFields(fields: {}): { - name: number; - username: number; - emails: number; - roles: number; - status: number; - active: number; - avatarETag: number; - lastLogin: number; -} { +export function getNonEmptyFields(fields: { [k: string]: 1 | 0 }): { [k: string]: 1 } { const defaultFields = { name: 1, username: 1, @@ -88,7 +79,7 @@ export function getNonEmptyFields(fields: {}): { active: 1, avatarETag: 1, lastLogin: 1, - }; + } as const; if (!fields || Object.keys(fields).length === 0) { return defaultFields; diff --git a/apps/meteor/app/api/server/v1/assets.js b/apps/meteor/app/api/server/v1/assets.ts similarity index 73% rename from apps/meteor/app/api/server/v1/assets.js rename to apps/meteor/app/api/server/v1/assets.ts index 7138d565034b..655d4fa49e23 100644 --- a/apps/meteor/app/api/server/v1/assets.js +++ b/apps/meteor/app/api/server/v1/assets.ts @@ -1,4 +1,5 @@ import { Meteor } from 'meteor/meteor'; +import { isAssetsUnsetAssetProps } from '@rocket.chat/rest-typings'; import { RocketChatAssets } from '../../../assets/server'; import { API } from '../api'; @@ -8,12 +9,10 @@ API.v1.addRoute( 'assets.setAsset', { authRequired: true }, { - post() { - const [asset, { refreshAllClients }, assetName] = Promise.await( - getUploadFormData({ - request: this.request, - }), - ); + async post() { + const [asset, { refreshAllClients }, assetName] = await getUploadFormData({ + request: this.request, + }); const assetsKeys = Object.keys(RocketChatAssets.assets); @@ -36,7 +35,10 @@ API.v1.addRoute( API.v1.addRoute( 'assets.unsetAsset', - { authRequired: true }, + { + authRequired: true, + validateParams: isAssetsUnsetAssetProps, + }, { post() { const { assetName, refreshAllClients } = this.bodyParams; @@ -44,12 +46,10 @@ API.v1.addRoute( if (!isValidAsset) { throw new Meteor.Error('error-invalid-asset', 'Invalid asset'); } - Meteor.runAsUser(this.userId, () => { - Meteor.call('unsetAsset', assetName); - if (refreshAllClients) { - Meteor.call('refreshClients'); - } - }); + Meteor.call('unsetAsset', assetName); + if (refreshAllClients) { + Meteor.call('refreshClients'); + } return API.v1.success(); }, }, diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index 157f1ac6d894..5e82084e6095 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -141,11 +141,9 @@ API.v1.addRoute( }); } - await Promise.all([ - Team.unsetTeamIdOfRooms(this.userId, team._id), - Team.removeAllMembersFromTeam(team._id), - Team.deleteById(team._id), - ]); + await Promise.all([Team.unsetTeamIdOfRooms(this.userId, team._id), Team.removeAllMembersFromTeam(team._id)]); + + await Team.deleteById(team._id); return API.v1.success(); }, diff --git a/apps/meteor/app/api/server/v1/users.js b/apps/meteor/app/api/server/v1/users.ts similarity index 66% rename from apps/meteor/app/api/server/v1/users.js rename to apps/meteor/app/api/server/v1/users.ts index 094570518e2d..8e6808fd34b4 100644 --- a/apps/meteor/app/api/server/v1/users.js +++ b/apps/meteor/app/api/server/v1/users.ts @@ -1,57 +1,236 @@ +import { + isUserCreateParamsPOST, + isUserSetActiveStatusParamsPOST, + isUserDeactivateIdleParamsPOST, + isUsersInfoParamsGetProps, + isUserRegisterParamsPOST, + isUserLogoutParamsPOST, + isUsersListTeamsProps, + isUsersAutocompleteProps, + isUsersSetAvatarProps, + isUsersUpdateParamsPOST, + isUsersUpdateOwnBasicInfoParamsPOST, + isUsersSetPreferencesParamsPOST, +} from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { Accounts } from 'meteor/accounts-base'; import { Match, check } from 'meteor/check'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import _ from 'underscore'; +import { IExportOperation, IPersonalAccessToken, IUser } from '@rocket.chat/core-typings'; import { Users, Subscriptions } from '../../../models/server'; import { Users as UsersRaw } from '../../../models/server/raw'; -import { hasPermission } from '../../../authorization'; +import { hasPermission } from '../../../authorization/server'; import { settings } from '../../../settings/server'; -import { getURL } from '../../../utils'; import { validateCustomFields, saveUser, saveCustomFieldsWithoutValidation, checkUsernameAvailability, + setStatusText, setUserAvatar, saveCustomFields, - setStatusText, } from '../../../lib/server'; import { getFullUserDataByIdOrUsername } from '../../../lib/server/functions/getFullUserData'; import { API } from '../api'; -import { getUploadFormData } from '../lib/getUploadFormData'; import { findUsersToAutocomplete, getInclusiveFields, getNonEmptyFields, getNonEmptyQuery } from '../lib/users'; import { getUserForCheck, emailCheck } from '../../../2fa/server/code'; import { resetUserE2EEncriptionKey } from '../../../../server/lib/resetUserE2EKey'; -import { setUserStatus } from '../../../../imports/users-presence/server/activeUsers'; import { resetTOTP } from '../../../2fa/server/functions/resetTOTP'; import { Team } from '../../../../server/sdk'; import { isValidQuery } from '../lib/isValidQuery'; +import { setUserStatus } from '../../../../imports/users-presence/server/activeUsers'; +import { getURL } from '../../../utils/server'; +import { getUploadFormData } from '../lib/getUploadFormData'; API.v1.addRoute( - 'users.create', - { authRequired: true }, + 'users.getAvatar', + { authRequired: false }, + { + get() { + const user = this.getUserFromParams(); + + const url = getURL(`/avatar/${user.username}`, { cdn: false, full: true }); + this.response.setHeader('Location', url); + + return { + statusCode: 307, + body: url, + }; + }, + }, +); + +API.v1.addRoute( + 'users.update', + { authRequired: true, twoFactorRequired: true, validateParams: isUsersUpdateParamsPOST }, + { + post() { + const userData = { _id: this.bodyParams.userId, ...this.bodyParams.data }; + + Meteor.runAsUser(this.userId, () => saveUser(this.userId, userData)); + + if (this.bodyParams.data.customFields) { + saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields); + } + + if (typeof this.bodyParams.data.active !== 'undefined') { + const { + userId, + data: { active }, + confirmRelinquish, + } = this.bodyParams; + + Meteor.call('setUserActiveStatus', userId, active, Boolean(confirmRelinquish)); + } + const { fields } = this.parseJsonQuery(); + + return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields }) }); + }, + }, +); + +API.v1.addRoute( + 'users.updateOwnBasicInfo', + { authRequired: true, validateParams: isUsersUpdateOwnBasicInfoParamsPOST }, + { + post() { + const userData = { + email: this.bodyParams.data.email, + realname: this.bodyParams.data.name, + username: this.bodyParams.data.username, + nickname: this.bodyParams.data.nickname, + statusText: this.bodyParams.data.statusText, + newPassword: this.bodyParams.data.newPassword, + typedPassword: this.bodyParams.data.currentPassword, + }; + + // saveUserProfile now uses the default two factor authentication procedures, so we need to provide that + const twoFactorOptions = !userData.typedPassword + ? null + : { + twoFactorCode: userData.typedPassword, + twoFactorMethod: 'password', + }; + + Meteor.call('saveUserProfile', userData, this.bodyParams.customFields, twoFactorOptions); + + return API.v1.success({ + user: Users.findOneById(this.userId, { fields: API.v1.defaultFieldsToExclude }), + }); + }, + }, +); + +API.v1.addRoute( + 'users.setPreferences', + { authRequired: true, validateParams: isUsersSetPreferencesParamsPOST }, { post() { - check(this.bodyParams, { - email: String, - name: String, - password: String, - username: String, - active: Match.Maybe(Boolean), - bio: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - roles: Match.Maybe(Array), - joinDefaultChannels: Match.Maybe(Boolean), - requirePasswordChange: Match.Maybe(Boolean), - setRandomPassword: Match.Maybe(Boolean), - sendWelcomeEmail: Match.Maybe(Boolean), - verified: Match.Maybe(Boolean), - customFields: Match.Maybe(Object), + if (this.bodyParams.userId && this.bodyParams.userId !== this.userId && !hasPermission(this.userId, 'edit-other-user-info')) { + throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed'); + } + const userId = this.bodyParams.userId ? this.bodyParams.userId : this.userId; + if (!Users.findOneById(userId)) { + throw new Meteor.Error('error-invalid-user', 'The optional "userId" param provided does not match any users'); + } + + Meteor.runAsUser(userId, () => Meteor.call('saveUserPreferences', this.bodyParams.data)); + const user = Users.findOneById(userId, { + fields: { + 'settings.preferences': 1, + 'language': 1, + }, }); + return API.v1.success({ + user: { + _id: user._id, + settings: { + preferences: { + ...user.settings.preferences, + language: user.language, + }, + }, + }, + }); + }, + }, +); + +API.v1.addRoute( + 'users.setAvatar', + { authRequired: true, validateParams: isUsersSetAvatarProps }, + { + async post() { + const canEditOtherUserAvatar = hasPermission(this.userId, 'edit-other-user-avatar'); + + if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { + throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed', { + method: 'users.setAvatar', + }); + } + + let user = ((): IUser | undefined => { + if (this.isUserFromParams()) { + return Meteor.users.findOne(this.userId) as IUser | undefined; + } + if (canEditOtherUserAvatar) { + return this.getUserFromParams(); + } + })(); + + if (!user) { + return API.v1.unauthorized(); + } + + if (this.bodyParams.avatarUrl) { + setUserAvatar(user, this.bodyParams.avatarUrl, '', 'url'); + return API.v1.success(); + } + + const [image, fields] = await getUploadFormData( + { + request: this.request, + }, + { + field: 'image', + }, + ); + + if (!image) { + return API.v1.failure("The 'image' param is required"); + } + + const sentTheUserByFormData = fields.userId || fields.username; + if (sentTheUserByFormData) { + if (fields.userId) { + user = Users.findOneById(fields.userId, { fields: { username: 1 } }); + } else if (fields.username) { + user = Users.findOneByUsernameIgnoringCase(fields.username, { fields: { username: 1 } }); + } + + if (!user) { + throw new Meteor.Error('error-invalid-user', 'The optional "userId" or "username" param provided does not match any users'); + } + + const isAnotherUser = this.userId !== user._id; + if (isAnotherUser && !hasPermission(this.userId, 'edit-other-user-avatar')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } + } + setUserAvatar(user, image.fileBuffer, image.mimetype, 'rest'); + + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'users.create', + { authRequired: true, validateParams: isUserCreateParamsPOST }, + { + post() { // New change made by pull request #5152 if (typeof this.bodyParams.joinDefaultChannels === 'undefined') { this.bodyParams.joinDefaultChannels = true; @@ -68,9 +247,7 @@ API.v1.addRoute( } if (typeof this.bodyParams.active !== 'undefined') { - Meteor.runAsUser(this.userId, () => { - Meteor.call('setUserActiveStatus', newUserId, this.bodyParams.active); - }); + Meteor.call('setUserActiveStatus', newUserId, this.bodyParams.active); } const { fields } = this.parseJsonQuery(); @@ -92,9 +269,7 @@ API.v1.addRoute( const user = this.getUserFromParams(); const { confirmRelinquish = false } = this.requestParams(); - Meteor.runAsUser(this.userId, () => { - Meteor.call('deleteUser', user._id, confirmRelinquish); - }); + Meteor.call('deleteUser', user._id, confirmRelinquish); return API.v1.success(); }, @@ -116,52 +291,24 @@ API.v1.addRoute( const { confirmRelinquish = false } = this.requestParams(); - Meteor.runAsUser(this.userId, () => { - Meteor.call('deleteUserOwnAccount', password, confirmRelinquish); - }); + Meteor.call('deleteUserOwnAccount', password, confirmRelinquish); return API.v1.success(); }, }, ); -API.v1.addRoute( - 'users.getAvatar', - { authRequired: false }, - { - get() { - const user = this.getUserFromParams(); - - const url = getURL(`/avatar/${user.username}`, { cdn: false, full: true }); - this.response.setHeader('Location', url); - - return { - statusCode: 307, - body: url, - }; - }, - }, -); - API.v1.addRoute( 'users.setActiveStatus', - { authRequired: true }, + { authRequired: true, validateParams: isUserSetActiveStatusParamsPOST }, { post() { - check(this.bodyParams, { - userId: String, - activeStatus: Boolean, - confirmRelinquish: Match.Maybe(Boolean), - }); - if (!hasPermission(this.userId, 'edit-other-user-active-status')) { return API.v1.unauthorized(); } - Meteor.runAsUser(this.userId, () => { - const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; - Meteor.call('setUserActiveStatus', userId, activeStatus, confirmRelinquish); - }); + const { userId, activeStatus, confirmRelinquish = false } = this.bodyParams; + Meteor.call('setUserActiveStatus', userId, activeStatus, confirmRelinquish); return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields: { active: 1 } }), }); @@ -171,14 +318,9 @@ API.v1.addRoute( API.v1.addRoute( 'users.deactivateIdle', - { authRequired: true }, + { authRequired: true, validateParams: isUserDeactivateIdleParamsPOST }, { post() { - check(this.bodyParams, { - daysIdle: Match.Integer, - role: Match.Optional(String), - }); - if (!hasPermission(this.userId, 'edit-other-user-active-status')) { return API.v1.unauthorized(); } @@ -197,68 +339,41 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.getPresence', - { authRequired: true }, - { - get() { - if (this.isUserFromParams()) { - const user = Users.findOneById(this.userId); - return API.v1.success({ - presence: user.status, - connectionStatus: user.statusConnection, - lastLogin: user.lastLogin, - }); - } - - const user = this.getUserFromParams(); - - return API.v1.success({ - presence: user.status, - }); - }, - }, -); - API.v1.addRoute( 'users.info', - { authRequired: true }, + { authRequired: true, validateParams: isUsersInfoParamsGetProps }, { - get() { - const { username, userId } = this.requestParams(); + async get() { const { fields } = this.parseJsonQuery(); - check(userId, Match.Maybe(String)); - check(username, Match.Maybe(String)); - - if (userId !== undefined && username !== undefined) { - throw new Meteor.Error('invalid-filter', 'Cannot filter by id and username at once'); - } - - if (!userId && !username) { - throw new Meteor.Error('invalid-filter', 'Must filter by id or username'); - } - - const user = getFullUserDataByIdOrUsername({ userId: this.userId, filterId: userId, filterUsername: username }); + const user = await getFullUserDataByIdOrUsername(this.userId, { + filterId: (this.queryParams as any).userId, + filterUsername: (this.queryParams as any).username, + }); if (!user) { return API.v1.failure('User not found.'); } const myself = user._id === this.userId; if (fields.userRooms === 1 && (myself || hasPermission(this.userId, 'view-other-user-channels'))) { - user.rooms = Subscriptions.findByUserId(user._id, { - fields: { - rid: 1, - name: 1, - t: 1, - roles: 1, - unread: 1, - }, - sort: { - t: 1, - name: 1, + return API.v1.success({ + user: { + ...user, + rooms: Subscriptions.findByUserId(user._id, { + projection: { + rid: 1, + name: 1, + t: 1, + roles: 1, + unread: 1, + }, + sort: { + t: 1, + name: 1, + }, + }).fetch(), }, - }).fetch(); + }); } return API.v1.success({ @@ -298,14 +413,14 @@ API.v1.addRoute( inclusiveFieldsKeys.includes('emails') && 'emails.address.*', inclusiveFieldsKeys.includes('username') && 'username.*', inclusiveFieldsKeys.includes('name') && 'name.*', - ].filter(Boolean), + ].filter(Boolean) as string[], this.queryOperations, ) ) { throw new Meteor.Error('error-invalid-query', isValidQuery.errors.join('\n')); } - const actualSort = sort && sort.name ? { nameInsensitive: sort.name, ...sort } : sort || { username: 1 }; + const actualSort = sort?.name ? { nameInsensitive: sort.name, ...sort } : sort || { username: 1 }; const limit = count !== 0 @@ -373,6 +488,7 @@ API.v1.addRoute( numRequestsAllowed: settings.get('Rate_Limiter_Limit_RegisterUser'), intervalTimeInMS: settings.get('API_Enable_Rate_Limiter_Limit_Time_Default'), }, + validateParams: isUserRegisterParamsPOST, }, { post() { @@ -380,306 +496,40 @@ API.v1.addRoute( return API.v1.failure('Logged in users can not register again.'); } - // We set their username here, so require it - // The `registerUser` checks for the other requirements - check( - this.bodyParams, - Match.ObjectIncluding({ - username: String, - }), - ); - if (!checkUsernameAvailability(this.bodyParams.username)) { return API.v1.failure('Username is already in use'); } // Register the user - const userId = Meteor.call('registerUser', this.bodyParams); - - // Now set their username - Meteor.runAsUser(userId, () => Meteor.call('setUsername', this.bodyParams.username)); - const { fields } = this.parseJsonQuery(); - - return API.v1.success({ user: Users.findOneById(userId, { fields }) }); - }, - }, -); - -API.v1.addRoute( - 'users.resetAvatar', - { authRequired: true }, - { - post() { - const user = this.getUserFromParams(); - - if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { - Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar')); - } else if (hasPermission(this.userId, 'edit-other-user-avatar')) { - Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar', user._id)); - } else { - throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { - method: 'users.resetAvatar', - }); - } - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.setAvatar', - { authRequired: true }, - { - async post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - avatarUrl: Match.Maybe(String), - userId: Match.Maybe(String), - username: Match.Maybe(String), - }), - ); - const canEditOtherUserAvatar = hasPermission(this.userId, 'edit-other-user-avatar'); - - if (!settings.get('Accounts_AllowUserAvatarChange') && !canEditOtherUserAvatar) { - throw new Meteor.Error('error-not-allowed', 'Change avatar is not allowed', { - method: 'users.setAvatar', - }); - } - - let user; - if (this.isUserFromParams()) { - user = Meteor.users.findOne(this.userId); - } else if (canEditOtherUserAvatar) { - user = this.getUserFromParams(); - } else { - return API.v1.unauthorized(); - } - - if (this.bodyParams.avatarUrl) { - setUserAvatar(user, this.bodyParams.avatarUrl, '', 'url'); - return API.v1.success(); - } - - const [image, fields] = await getUploadFormData( - { - request: this.request, - }, - { field: 'image' }, - ); - - if (!image) { - return API.v1.failure("The 'image' param is required"); - } - - const sentTheUserByFormData = fields.userId || fields.username; - if (sentTheUserByFormData) { - if (fields.userId) { - user = Users.findOneById(fields.userId, { fields: { username: 1 } }); - } else if (fields.username) { - user = Users.findOneByUsernameIgnoringCase(fields.username, { fields: { username: 1 } }); - } - - if (!user) { - throw new Meteor.Error('error-invalid-user', 'The optional "userId" or "username" param provided does not match any users'); - } - - const isAnotherUser = this.userId !== user._id; - if (isAnotherUser && !hasPermission(this.userId, 'edit-other-user-avatar')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } - } - - setUserAvatar(user, image.fileBuffer, image.mimetype, 'rest'); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.getStatus', - { authRequired: true }, - { - get() { - if (this.isUserFromParams()) { - const user = Users.findOneById(this.userId); - return API.v1.success({ - _id: user._id, - message: user.statusText, - connectionStatus: user.statusConnection, - status: user.status, - }); - } - - const user = this.getUserFromParams(); - - return API.v1.success({ - _id: user._id, - message: user.statusText, - status: user.status, - }); - }, - }, -); - -API.v1.addRoute( - 'users.setStatus', - { authRequired: true }, - { - post() { - check( - this.bodyParams, - Match.ObjectIncluding({ - status: Match.Maybe(String), - message: Match.Maybe(String), - }), - ); - - if (!settings.get('Accounts_AllowUserStatusMessageChange')) { - throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', { - method: 'users.setStatus', - }); - } - - let user; - if (this.isUserFromParams()) { - user = Meteor.users.findOne(this.userId); - } else if (hasPermission(this.userId, 'edit-other-user-info')) { - user = this.getUserFromParams(); - } else { - return API.v1.unauthorized(); - } - - Meteor.runAsUser(user._id, () => { - if (this.bodyParams.message || this.bodyParams.message === '') { - setStatusText(user._id, this.bodyParams.message); - } - if (this.bodyParams.status) { - const validStatus = ['online', 'away', 'offline', 'busy']; - if (validStatus.includes(this.bodyParams.status)) { - const { status } = this.bodyParams; - - if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { - throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { - method: 'users.setStatus', - }); - } - - Meteor.users.update(user._id, { - $set: { - status, - statusDefault: status, - }, - }); - - setUserStatus(user, status); - } else { - throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { - method: 'users.setStatus', - }); - } - } - }); - - return API.v1.success(); - }, - }, -); - -API.v1.addRoute( - 'users.update', - { authRequired: true, twoFactorRequired: true }, - { - post() { - check(this.bodyParams, { - userId: String, - data: Match.ObjectIncluding({ - email: Match.Maybe(String), - name: Match.Maybe(String), - password: Match.Maybe(String), - username: Match.Maybe(String), - bio: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - active: Match.Maybe(Boolean), - roles: Match.Maybe(Array), - joinDefaultChannels: Match.Maybe(Boolean), - requirePasswordChange: Match.Maybe(Boolean), - sendWelcomeEmail: Match.Maybe(Boolean), - verified: Match.Maybe(Boolean), - customFields: Match.Maybe(Object), - }), - }); - - const userData = _.extend({ _id: this.bodyParams.userId }, this.bodyParams.data); - - Meteor.runAsUser(this.userId, () => saveUser(this.userId, userData)); - - if (this.bodyParams.data.customFields) { - saveCustomFields(this.bodyParams.userId, this.bodyParams.data.customFields); - } - - if (typeof this.bodyParams.data.active !== 'undefined') { - const { - userId, - data: { active }, - confirmRelinquish = false, - } = this.bodyParams; + const userId = Meteor.call('registerUser', this.bodyParams); - Meteor.runAsUser(this.userId, () => { - Meteor.call('setUserActiveStatus', userId, active, confirmRelinquish); - }); - } + // Now set their username + Meteor.runAsUser(userId, () => Meteor.call('setUsername', this.bodyParams.username)); const { fields } = this.parseJsonQuery(); - return API.v1.success({ user: Users.findOneById(this.bodyParams.userId, { fields }) }); + return API.v1.success({ user: Users.findOneById(userId, { fields }) }); }, }, ); API.v1.addRoute( - 'users.updateOwnBasicInfo', + 'users.resetAvatar', { authRequired: true }, { post() { - check(this.bodyParams, { - data: Match.ObjectIncluding({ - email: Match.Maybe(String), - name: Match.Maybe(String), - username: Match.Maybe(String), - nickname: Match.Maybe(String), - statusText: Match.Maybe(String), - currentPassword: Match.Maybe(String), - newPassword: Match.Maybe(String), - }), - customFields: Match.Maybe(Object), - }); - - const userData = { - email: this.bodyParams.data.email, - realname: this.bodyParams.data.name, - username: this.bodyParams.data.username, - nickname: this.bodyParams.data.nickname, - statusText: this.bodyParams.data.statusText, - newPassword: this.bodyParams.data.newPassword, - typedPassword: this.bodyParams.data.currentPassword, - }; - - // saveUserProfile now uses the default two factor authentication procedures, so we need to provide that - const twoFactorOptions = !userData.typedPassword - ? null - : { - twoFactorCode: userData.typedPassword, - twoFactorMethod: 'password', - }; + const user = this.getUserFromParams(); - Meteor.runAsUser(this.userId, () => Meteor.call('saveUserProfile', userData, this.bodyParams.customFields, twoFactorOptions)); + if (settings.get('Accounts_AllowUserAvatarChange') && user._id === this.userId) { + Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar')); + } else if (hasPermission(this.userId, 'edit-other-user-avatar')) { + Meteor.runAsUser(this.userId, () => Meteor.call('resetAvatar', user._id)); + } else { + throw new Meteor.Error('error-not-allowed', 'Reset avatar is not allowed', { + method: 'users.resetAvatar', + }); + } - return API.v1.success({ - user: Users.findOneById(this.userId, { fields: API.v1.defaultFieldsToExclude }), - }); + return API.v1.success(); }, }, ); @@ -690,10 +540,7 @@ API.v1.addRoute( { post() { const user = this.getUserFromParams(); - let data; - Meteor.runAsUser(this.userId, () => { - data = Meteor.call('createToken', user._id); - }); + const data = Meteor.call('createToken', user._id); return data ? API.v1.success({ data }) : API.v1.unauthorized(); }, }, @@ -718,77 +565,6 @@ API.v1.addRoute( }, ); -API.v1.addRoute( - 'users.setPreferences', - { authRequired: true }, - { - post() { - check(this.bodyParams, { - userId: Match.Maybe(String), - data: Match.ObjectIncluding({ - newRoomNotification: Match.Maybe(String), - newMessageNotification: Match.Maybe(String), - clockMode: Match.Maybe(Number), - useEmojis: Match.Maybe(Boolean), - convertAsciiEmoji: Match.Maybe(Boolean), - saveMobileBandwidth: Match.Maybe(Boolean), - collapseMediaByDefault: Match.Maybe(Boolean), - autoImageLoad: Match.Maybe(Boolean), - emailNotificationMode: Match.Maybe(String), - unreadAlert: Match.Maybe(Boolean), - notificationsSoundVolume: Match.Maybe(Number), - desktopNotifications: Match.Maybe(String), - pushNotifications: Match.Maybe(String), - enableAutoAway: Match.Maybe(Boolean), - highlights: Match.Maybe(Array), - desktopNotificationRequireInteraction: Match.Maybe(Boolean), - messageViewMode: Match.Maybe(Number), - showMessageInMainThread: Match.Maybe(Boolean), - hideUsernames: Match.Maybe(Boolean), - hideRoles: Match.Maybe(Boolean), - displayAvatars: Match.Maybe(Boolean), - hideFlexTab: Match.Maybe(Boolean), - sendOnEnter: Match.Maybe(String), - language: Match.Maybe(String), - sidebarShowFavorites: Match.Optional(Boolean), - sidebarShowUnread: Match.Optional(Boolean), - sidebarSortby: Match.Optional(String), - sidebarViewMode: Match.Optional(String), - sidebarDisplayAvatar: Match.Optional(Boolean), - sidebarGroupByType: Match.Optional(Boolean), - muteFocusedConversations: Match.Optional(Boolean), - }), - }); - if (this.bodyParams.userId && this.bodyParams.userId !== this.userId && !hasPermission(this.userId, 'edit-other-user-info')) { - throw new Meteor.Error('error-action-not-allowed', 'Editing user is not allowed'); - } - const userId = this.bodyParams.userId ? this.bodyParams.userId : this.userId; - if (!Users.findOneById(userId)) { - throw new Meteor.Error('error-invalid-user', 'The optional "userId" param provided does not match any users'); - } - - Meteor.runAsUser(userId, () => Meteor.call('saveUserPreferences', this.bodyParams.data)); - const user = Users.findOneById(userId, { - fields: { - 'settings.preferences': 1, - 'language': 1, - }, - }); - return API.v1.success({ - user: { - _id: user._id, - settings: { - preferences: { - ...user.settings.preferences, - language: user.language, - }, - }, - }, - }); - }, - }, -); - API.v1.addRoute( 'users.forgotPassword', { authRequired: false }, @@ -810,7 +586,7 @@ API.v1.addRoute( { authRequired: true }, { get() { - const result = Meteor.runAsUser(this.userId, () => Meteor.call('getUsernameSuggestion')); + const result = Meteor.call('getUsernameSuggestion'); return API.v1.success({ result }); }, @@ -826,7 +602,7 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - const token = Meteor.runAsUser(this.userId, () => Meteor.call('personalAccessTokens:generateToken', { tokenName, bypassTwoFactor })); + const token = Meteor.call('personalAccessTokens:generateToken', { tokenName, bypassTwoFactor }); return API.v1.success({ token }); }, @@ -842,7 +618,7 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - const token = Meteor.runAsUser(this.userId, () => Meteor.call('personalAccessTokens:regenerateToken', { tokenName })); + const token = Meteor.call('personalAccessTokens:regenerateToken', { tokenName }); return API.v1.success({ token }); }, @@ -857,19 +633,19 @@ API.v1.addRoute( if (!hasPermission(this.userId, 'create-personal-access-tokens')) { throw new Meteor.Error('not-authorized', 'Not Authorized'); } - const loginTokens = Users.getLoginTokensByUserId(this.userId).fetch()[0]; - const getPersonalAccessTokens = () => - loginTokens.services.resume.loginTokens - .filter((loginToken) => loginToken.type && loginToken.type === 'personalAccessToken') - .map((loginToken) => ({ - name: loginToken.name, - createdAt: loginToken.createdAt, - lastTokenPart: loginToken.lastTokenPart, - bypassTwoFactor: loginToken.bypassTwoFactor, - })); + + const user = Users.getLoginTokensByUserId(this.userId).fetch()[0] as IUser | undefined; return API.v1.success({ - tokens: loginTokens ? getPersonalAccessTokens() : [], + tokens: + user?.services?.resume?.loginTokens + ?.filter((loginToken: any) => loginToken.type === 'personalAccessToken') + .map((loginToken: IPersonalAccessToken) => ({ + name: loginToken.name, + createdAt: loginToken.createdAt.toISOString(), + lastTokenPart: loginToken.lastTokenPart, + bypassTwoFactor: Boolean(loginToken.bypassTwoFactor), + })) || [], }); }, }, @@ -884,11 +660,9 @@ API.v1.addRoute( if (!tokenName) { return API.v1.failure("The 'tokenName' param is required"); } - Meteor.runAsUser(this.userId, () => - Meteor.call('personalAccessTokens:removeToken', { - tokenName, - }), - ); + Meteor.call('personalAccessTokens:removeToken', { + tokenName, + }); return API.v1.success(); }, @@ -931,7 +705,7 @@ API.v1.addRoute('users.2fa.sendEmailCode', { const userId = this.userId || Users[method](emailOrUsername, { fields: { _id: 1 } })?._id; if (!userId) { - this.logger.error('[2fa] User was not found when requesting 2fa email code'); + // this.logger.error('[2fa] User was not found when requesting 2fa email code'); return API.v1.success(); } @@ -968,7 +742,7 @@ API.v1.addRoute( if (from) { const ts = new Date(from); - const diff = (Date.now() - ts) / 1000 / 60; + const diff = (Date.now() - Number(ts)) / 1000 / 60; if (diff < 10) { return API.v1.success({ @@ -992,10 +766,13 @@ API.v1.addRoute( { get() { const { fullExport = false } = this.queryParams; - const result = Meteor.runAsUser(this.userId, () => Meteor.call('requestDataDownload', { fullExport: fullExport === 'true' })); + const result = Meteor.call('requestDataDownload', { fullExport: fullExport === 'true' }) as { + requested: boolean; + exportOperation: IExportOperation; + }; return API.v1.success({ - requested: result.requested, + requested: Boolean(result.requested), exportOperation: result.exportOperation, }); }, @@ -1007,48 +784,43 @@ API.v1.addRoute( { authRequired: true }, { async post() { - try { - const hashedToken = Accounts._hashLoginToken(this.request.headers['x-auth-token']); + const xAuthToken = this.request.headers['x-auth-token'] as string; - if (!(await UsersRaw.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + if (!xAuthToken) { + throw new Meteor.Error('error-parameter-required', 'x-auth-token is required'); + } + const hashedToken = Accounts._hashLoginToken(xAuthToken); - const me = await UsersRaw.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1 } }); + if (!(await UsersRaw.removeNonPATLoginTokensExcept(this.userId, hashedToken))) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - const token = me.services.resume.loginTokens.find((token) => token.hashedToken === hashedToken); + const me = (await UsersRaw.findOneById(this.userId, { projection: { 'services.resume.loginTokens': 1 } })) as Pick; - const tokenExpires = new Date(token.when.getTime() + settings.get('Accounts_LoginExpiration') * 1000); + const token = me.services?.resume?.loginTokens?.find((token) => token.hashedToken === hashedToken); - return API.v1.success({ - token: this.request.headers['x-auth-token'], - tokenExpires, - }); - } catch (error) { - return API.v1.failure(error); - } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const tokenExpires = new Date(token!.when.getTime() + settings.get('Accounts_LoginExpiration') * 1000); + + return API.v1.success({ + token: xAuthToken, + tokenExpires: tokenExpires.toISOString() || '', + }); }, }, ); API.v1.addRoute( 'users.autocomplete', - { authRequired: true }, + { authRequired: true, validateParams: isUsersAutocompleteProps }, { - get() { + async get() { const { selector } = this.queryParams; - - if (!selector) { - return API.v1.failure("The 'selector' param is required"); - } - return API.v1.success( - Promise.await( - findUsersToAutocomplete({ - uid: this.userId, - selector: JSON.parse(selector), - }), - ), + await findUsersToAutocomplete({ + uid: this.userId, + selector: JSON.parse(selector), + }), ); }, }, @@ -1059,7 +831,7 @@ API.v1.addRoute( { authRequired: true }, { post() { - API.v1.success(Meteor.call('removeOtherTokens')); + return API.v1.success(Meteor.call('removeOtherTokens')); }, }, ); @@ -1069,30 +841,28 @@ API.v1.addRoute( { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, { post() { - // reset own keys - if (this.isUserFromParams()) { - resetUserE2EEncriptionKey(this.userId, false); - return API.v1.success(); - } + if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { + // reset other user keys + const user = this.getUserFromParams(); + if (!user) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - // reset other user keys - const user = this.getUserFromParams(); - if (!user) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!hasPermission(this.userId, 'edit-other-user-e2ee')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!hasPermission(Meteor.userId(), 'edit-other-user-e2ee')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!resetUserE2EEncriptionKey(user._id, true)) { + return API.v1.failure(); + } - if (!resetUserE2EEncriptionKey(user._id, true)) { - return API.v1.failure(); + return API.v1.success(); } - + resetUserE2EEncriptionKey(this.userId, false); return API.v1.success(); }, }, @@ -1102,29 +872,28 @@ API.v1.addRoute( 'users.resetTOTP', { authRequired: true, twoFactorRequired: true, twoFactorOptions: { disableRememberMe: true } }, { - post() { - // reset own keys - if (this.isUserFromParams()) { - Promise.await(resetTOTP(this.userId, false)); - return API.v1.success(); - } - - // reset other user keys - const user = this.getUserFromParams(); - if (!user) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + async post() { + // // reset own keys + if ('userId' in this.bodyParams || 'username' in this.bodyParams || 'user' in this.bodyParams) { + // reset other user keys + if (!hasPermission(this.userId, 'edit-other-user-totp')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + if (!settings.get('Accounts_TwoFactorAuthentication_Enforce_Password_Fallback')) { + throw new Meteor.Error('error-not-allowed', 'Not allowed'); + } - if (!hasPermission(Meteor.userId(), 'edit-other-user-totp')) { - throw new Meteor.Error('error-not-allowed', 'Not allowed'); - } + const user = this.getUserFromParams(); + if (!user) { + throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); + } - Promise.await(resetTOTP(user._id, true)); + await resetTOTP(user._id, true); + return API.v1.success(); + } + await resetTOTP(this.userId, false); return API.v1.success(); }, }, @@ -1132,25 +901,22 @@ API.v1.addRoute( API.v1.addRoute( 'users.listTeams', - { authRequired: true }, + { authRequired: true, validateParams: isUsersListTeamsProps }, { - get() { + async get() { check( this.queryParams, Match.ObjectIncluding({ userId: Match.Maybe(String), }), ); - const { userId } = this.queryParams; - if (!userId) { - throw new Meteor.Error('error-invalid-user-id', 'Invalid user id'); - } + const { userId } = this.queryParams; // If the caller has permission to view all teams, there's no need to filter the teams const adminId = hasPermission(this.userId, 'view-all-teams') ? undefined : this.userId; - const teams = Promise.await(Team.findBySubscribedUserIds(userId, adminId)); + const teams = await Team.findBySubscribedUserIds(userId, adminId); return API.v1.success({ teams, @@ -1161,7 +927,7 @@ API.v1.addRoute( API.v1.addRoute( 'users.logout', - { authRequired: true }, + { authRequired: true, validateParams: isUserLogoutParamsPOST }, { post() { const userId = this.bodyParams.userId || this.userId; @@ -1182,7 +948,130 @@ API.v1.addRoute( }, ); -settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { +API.v1.addRoute( + 'users.getPresence', + { authRequired: true }, + { + get() { + if (this.isUserFromParams()) { + const user = Users.findOneById(this.userId); + return API.v1.success({ + presence: user.status || 'offline', + connectionStatus: user.statusConnection || 'offline', + ...(user.lastLogin && { lastLogin: user.lastLogin }), + }); + } + + const user = this.getUserFromParams(); + + return API.v1.success({ + presence: user.status || 'offline', + }); + }, + }, +); + +API.v1.addRoute( + 'users.setStatus', + { authRequired: true }, + { + post() { + check( + this.bodyParams, + Match.ObjectIncluding({ + status: Match.Maybe(String), + message: Match.Maybe(String), + }), + ); + + if (!settings.get('Accounts_AllowUserStatusMessageChange')) { + throw new Meteor.Error('error-not-allowed', 'Change status is not allowed', { + method: 'users.setStatus', + }); + } + + const user = ((): IUser | undefined => { + if (this.isUserFromParams()) { + return Meteor.users.findOne(this.userId) as IUser; + } + if (hasPermission(this.userId, 'edit-other-user-info')) { + return this.getUserFromParams(); + } + })(); + + if (user === undefined) { + return API.v1.unauthorized(); + } + + Meteor.runAsUser(user._id, () => { + if (this.bodyParams.message || this.bodyParams.message === '') { + setStatusText(user._id, this.bodyParams.message); + } + if (this.bodyParams.status) { + const validStatus = ['online', 'away', 'offline', 'busy']; + if (validStatus.includes(this.bodyParams.status)) { + const { status } = this.bodyParams; + + if (status === 'offline' && !settings.get('Accounts_AllowInvisibleStatusOption')) { + throw new Meteor.Error('error-status-not-allowed', 'Invisible status is disabled', { + method: 'users.setStatus', + }); + } + + Meteor.users.update(user._id, { + $set: { + status, + statusDefault: status, + }, + }); + + setUserStatus(user, status); + } else { + throw new Meteor.Error('error-invalid-status', 'Valid status types include online, away, offline, and busy.', { + method: 'users.setStatus', + }); + } + } + }); + + return API.v1.success(); + }, + }, +); + +// status: 'online' | 'offline' | 'away' | 'busy'; +// message?: string; +// _id: string; +// connectionStatus?: 'online' | 'offline' | 'away' | 'busy'; +// }; + +API.v1.addRoute( + 'users.getStatus', + { authRequired: true }, + { + get() { + if (this.isUserFromParams()) { + const user = Users.findOneById(this.userId); + return API.v1.success({ + _id: user._id, + // message: user.statusText, + connectionStatus: (user.statusConnection || 'offline') as 'online' | 'offline' | 'away' | 'busy', + status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + }); + } + + const user = this.getUserFromParams(); + + return API.v1.success({ + _id: user._id, + // message: user.statusText, + status: (user.status || 'offline') as 'online' | 'offline' | 'away' | 'busy', + }); + }, + }, +); + +settings.watch('Rate_Limiter_Limit_RegisterUser', (value) => { const userRegisterRoute = '/api/v1/users.registerpost'; API.v1.updateRateLimiterDictionaryForRoute(userRegisterRoute, value); diff --git a/apps/meteor/app/assets/server/assets.js b/apps/meteor/app/assets/server/assets.ts similarity index 63% rename from apps/meteor/app/assets/server/assets.js rename to apps/meteor/app/assets/server/assets.ts index 79ced079ea60..84ed75f8480e 100644 --- a/apps/meteor/app/assets/server/assets.js +++ b/apps/meteor/app/assets/server/assets.ts @@ -1,4 +1,5 @@ import crypto from 'crypto'; +import { ServerResponse, IncomingMessage } from 'http'; import { Meteor } from 'meteor/meteor'; import { WebApp, WebAppInternals } from 'meteor/webapp'; @@ -6,19 +7,20 @@ import { WebAppHashing } from 'meteor/webapp-hashing'; import _ from 'underscore'; import sizeOf from 'image-size'; import sharp from 'sharp'; +import { NextHandleFunction } from 'connect'; +import { IRocketChatAssets, IRocketChatAsset } from '@rocket.chat/core-typings'; import { settings, settingsRegistry } from '../../settings/server'; import { getURL } from '../../utils/lib/getURL'; -import { mime } from '../../utils/lib/mimeTypes'; -import { hasPermission } from '../../authorization'; +import { getExtension } from '../../utils/lib/mimeTypes'; +import { hasPermission } from '../../authorization/server'; import { RocketChatFile } from '../../file'; import { Settings } from '../../models/server'; const RocketChatAssetsInstance = new RocketChatFile.GridFS({ name: 'assets', }); - -const assets = { +const assets: IRocketChatAssets = { logo: { label: 'logo (svg, png, jpg)', defaultUrl: 'images/logo/logo.svg', @@ -38,6 +40,7 @@ const assets = { extensions: ['svg', 'png', 'jpg', 'jpeg'], }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_ico: { label: 'favicon (ico)', defaultUrl: 'favicon.ico', @@ -54,6 +57,7 @@ const assets = { extensions: ['svg'], }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_16: { label: 'favicon 16x16 (png)', defaultUrl: 'images/logo/favicon-16x16.png', @@ -64,6 +68,7 @@ const assets = { height: 16, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_32: { label: 'favicon 32x32 (png)', defaultUrl: 'images/logo/favicon-32x32.png', @@ -74,6 +79,7 @@ const assets = { height: 32, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_192: { label: 'android-chrome 192x192 (png)', defaultUrl: 'images/logo/android-chrome-192x192.png', @@ -84,6 +90,7 @@ const assets = { height: 192, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase favicon_512: { label: 'android-chrome 512x512 (png)', defaultUrl: 'images/logo/android-chrome-512x512.png', @@ -94,6 +101,7 @@ const assets = { height: 512, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase touchicon_180: { label: 'apple-touch-icon 180x180 (png)', defaultUrl: 'images/logo/apple-touch-icon.png', @@ -104,6 +112,7 @@ const assets = { height: 180, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase touchicon_180_pre: { label: 'apple-touch-icon-precomposed 180x180 (png)', defaultUrl: 'images/logo/apple-touch-icon-precomposed.png', @@ -114,6 +123,7 @@ const assets = { height: 180, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_70: { label: 'mstile 70x70 (png)', defaultUrl: 'images/logo/mstile-70x70.png', @@ -124,6 +134,7 @@ const assets = { height: 70, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_144: { label: 'mstile 144x144 (png)', defaultUrl: 'images/logo/mstile-144x144.png', @@ -134,6 +145,7 @@ const assets = { height: 144, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_150: { label: 'mstile 150x150 (png)', defaultUrl: 'images/logo/mstile-150x150.png', @@ -144,6 +156,7 @@ const assets = { height: 150, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_310_square: { label: 'mstile 310x310 (png)', defaultUrl: 'images/logo/mstile-310x310.png', @@ -154,6 +167,7 @@ const assets = { height: 310, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase tile_310_wide: { label: 'mstile 310x150 (png)', defaultUrl: 'images/logo/mstile-310x150.png', @@ -164,6 +178,7 @@ const assets = { height: 150, }, }, + // eslint-disable-next-line @typescript-eslint/camelcase safari_pinned: { label: 'safari pinned tab (svg)', defaultUrl: 'images/logo/safari-pinned-tab.svg', @@ -174,24 +189,25 @@ const assets = { }, }; -export const RocketChatAssets = new (class { - get mime() { - return mime; - } +function getAssetByKey(key: string): IRocketChatAsset { + return assets[key as keyof IRocketChatAssets]; +} - get assets() { +class RocketChatAssetsClass { + get assets(): IRocketChatAssets { return assets; } - setAsset(binaryContent, contentType, asset) { - if (!assets[asset]) { + public setAsset(binaryContent: BufferEncoding, contentType: string, asset: string): void { + const assetInstance = getAssetByKey(asset); + if (!assetInstance) { throw new Meteor.Error('error-invalid-asset', 'Invalid asset', { function: 'RocketChat.Assets.setAsset', }); } - const extension = mime.extension(contentType); - if (assets[asset].constraints.extensions.includes(extension) === false) { + const extension = getExtension(contentType); + if (assetInstance.constraints.extensions.includes(extension) === false) { throw new Meteor.Error(contentType, `Invalid file type: ${contentType}`, { function: 'RocketChat.Assets.setAsset', errorTitle: 'error-invalid-file-type', @@ -199,14 +215,14 @@ export const RocketChatAssets = new (class { } const file = Buffer.from(binaryContent, 'binary'); - if (assets[asset].constraints.width || assets[asset].constraints.height) { + if (assetInstance.constraints.width || assetInstance.constraints.height) { const dimensions = sizeOf(file); - if (assets[asset].constraints.width && assets[asset].constraints.width !== dimensions.width) { + if (assetInstance.constraints.width && assetInstance.constraints.width !== dimensions.width) { throw new Meteor.Error('error-invalid-file-width', 'Invalid file width', { function: 'Invalid file width', }); } - if (assets[asset].constraints.height && assets[asset].constraints.height !== dimensions.height) { + if (assetInstance.constraints.height && assetInstance.constraints.height !== dimensions.height) { throw new Meteor.Error('error-invalid-file-height'); } } @@ -222,10 +238,11 @@ export const RocketChatAssets = new (class { const key = `Assets_${asset}`; const value = { url: `assets/${asset}.${extension}`, - defaultUrl: assets[asset].defaultUrl, + defaultUrl: assetInstance.defaultUrl, }; Settings.updateValueById(key, value); + // eslint-disable-next-line @typescript-eslint/no-use-before-define return RocketChatAssets.processAsset(key, value); }, 200); }), @@ -234,8 +251,8 @@ export const RocketChatAssets = new (class { rs.pipe(ws); } - unsetAsset(asset) { - if (!assets[asset]) { + public unsetAsset(asset: string): void { + if (!getAssetByKey(asset)) { throw new Meteor.Error('error-invalid-asset', 'Invalid asset', { function: 'RocketChat.Assets.unsetAsset', }); @@ -244,26 +261,27 @@ export const RocketChatAssets = new (class { RocketChatAssetsInstance.deleteFile(asset); const key = `Assets_${asset}`; const value = { - defaultUrl: assets[asset].defaultUrl, + defaultUrl: getAssetByKey(asset).defaultUrl, }; Settings.updateValueById(key, value); + // eslint-disable-next-line @typescript-eslint/no-use-before-define RocketChatAssets.processAsset(key, value); } - refreshClients() { - return process.emit('message', { + public refreshClients(): boolean { + return (process.emit as Function)('message', { refresh: 'client', }); } - processAsset(settingKey, settingValue) { + public processAsset(settingKey: string, settingValue: any): Record | undefined { if (settingKey.indexOf('Assets_') !== 0) { return; } const assetKey = settingKey.replace(/^Assets_/, ''); - const assetValue = assets[assetKey]; + const assetValue = getAssetByKey(assetKey); if (!assetValue) { return; @@ -301,23 +319,25 @@ export const RocketChatAssets = new (class { return assetValue.cache; } - getURL(assetName, options = { cdn: false, full: true }) { - const asset = settings.get(assetName); + public getURL(assetName: string, options = { cdn: false, full: true }): string { + const asset = settings.get(assetName); const url = asset.url || asset.defaultUrl; return getURL(url, options); } -})(); +} -settingsRegistry.addGroup('Assets'); +export const RocketChatAssets = new RocketChatAssetsClass(); -settingsRegistry.add('Assets_SvgFavicon_Enable', true, { - type: 'boolean', - group: 'Assets', - i18nLabel: 'Enable_Svg_Favicon', +settingsRegistry.addGroup('Assets', function () { + this.add('Assets_SvgFavicon_Enable', true, { + type: 'boolean', + group: 'Assets', + i18nLabel: 'Enable_Svg_Favicon', + }); }); -function addAssetToSetting(asset, value) { +function addAssetToSetting(asset: string, value: IRocketChatAsset): void { const key = `Assets_${asset}`; settingsRegistry.add( @@ -336,16 +356,16 @@ function addAssetToSetting(asset, value) { }, ); - const currentValue = settings.get(key); + const currentValue = settings.get(key); - if (typeof currentValue === 'object' && currentValue.defaultUrl !== assets[asset].defaultUrl) { - currentValue.defaultUrl = assets[asset].defaultUrl; + if (typeof currentValue === 'object' && currentValue.defaultUrl !== getAssetByKey(asset).defaultUrl) { + currentValue.defaultUrl = getAssetByKey(asset).defaultUrl; Settings.updateValueById(key, currentValue); } } for (const key of Object.keys(assets)) { - const value = assets[key]; + const value = getAssetByKey(key); addAssetToSetting(key, value); } @@ -353,7 +373,7 @@ settings.watchByRegex(/^Assets_/, (key, value) => RocketChatAssets.processAsset( Meteor.startup(function () { return Meteor.setTimeout(function () { - return process.emit('message', { + return (process.emit as Function)('message', { refresh: 'client', }); }, 200); @@ -361,9 +381,9 @@ Meteor.startup(function () { const { calculateClientHash } = WebAppHashing; -WebAppHashing.calculateClientHash = function (manifest, includeFilter, runtimeConfigOverride) { +WebAppHashing.calculateClientHash = function (manifest: Record, includeFilter: Function, runtimeConfigOverride: any): string { for (const key of Object.keys(assets)) { - const value = assets[key]; + const value = getAssetByKey(key); if (!value.cache && !value.defaultUrl) { continue; } @@ -381,7 +401,7 @@ WebAppHashing.calculateClientHash = function (manifest, includeFilter, runtimeCo hash: value.cache.hash, }; } else { - const extension = value.defaultUrl.split('.').pop(); + const extension = value.defaultUrl?.split('.').pop(); cache = { path: `assets/${key}.${extension}`, cacheable: false, @@ -416,7 +436,7 @@ Meteor.methods({ }); } - const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + const _hasPermission = hasPermission(Meteor.userId() as string, 'manage-assets'); if (!_hasPermission) { throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { method: 'refreshClients', @@ -434,7 +454,7 @@ Meteor.methods({ }); } - const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + const _hasPermission = hasPermission(Meteor.userId() as string, 'manage-assets'); if (!_hasPermission) { throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { method: 'unsetAsset', @@ -452,7 +472,7 @@ Meteor.methods({ }); } - const _hasPermission = hasPermission(Meteor.userId(), 'manage-assets'); + const _hasPermission = hasPermission(Meteor.userId() as string, 'manage-assets'); if (!_hasPermission) { throw new Meteor.Error('error-action-not-allowed', 'Managing assets not allowed', { method: 'setAsset', @@ -464,62 +484,63 @@ Meteor.methods({ }, }); -WebApp.connectHandlers.use( - '/assets/', - Meteor.bindEnvironment(function (req, res, next) { - const params = { - asset: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')).replace(/\.[^.]*$/, ''), - }; +const listener = Meteor.bindEnvironment((req: IncomingMessage, res: ServerResponse, next: NextHandleFunction) => { + if (!req.url) { + return; + } + const params = { + asset: decodeURIComponent(req.url.replace(/^\//, '').replace(/\?.*$/, '')).replace(/\.[^.]*$/, ''), + }; - const file = assets[params.asset] && assets[params.asset].cache; + const asset = getAssetByKey(params.asset); + const file = asset?.cache; - const format = req.url.replace(/.*\.([a-z]+)(?:$|\?.*)/i, '$1'); + const format = req.url.split('.').pop() || ''; - if ( - assets[params.asset] && - Array.isArray(assets[params.asset].constraints.extensions) && - !assets[params.asset].constraints.extensions.includes(format) - ) { - res.writeHead(403); - return res.end(); + if (asset && Array.isArray(asset.constraints.extensions) && !asset.constraints.extensions.includes(format)) { + res.writeHead(403); + return res.end(); + } + if (!file) { + const defaultUrl = asset?.defaultUrl; + if (defaultUrl) { + const assetUrl = format && ['png', 'svg'].includes(format) ? defaultUrl.replace(/(svg|png)$/, format) : defaultUrl; + req.url = `/${assetUrl}`; + WebAppInternals.staticFilesMiddleware((WebAppInternals as Record).staticFilesByArch, req, res, next); + } else { + res.writeHead(404); + res.end(); } - if (!file) { - const defaultUrl = assets[params.asset] && assets[params.asset].defaultUrl; - if (defaultUrl) { - const assetUrl = format && ['png', 'svg'].includes(format) ? defaultUrl.replace(/(svg|png)$/, format) : defaultUrl; - req.url = `/${assetUrl}`; - WebAppInternals.staticFilesMiddleware(WebAppInternals.staticFilesByArch, req, res, next); - } else { - res.writeHead(404); - res.end(); - } + return; + } + + const reqModifiedHeader = req.headers['if-modified-since']; + if (reqModifiedHeader) { + if (reqModifiedHeader === (file.uploadDate && file.uploadDate.toUTCString())) { + res.setHeader('Last-Modified', reqModifiedHeader); + res.writeHead(304); + res.end(); return; } + } - const reqModifiedHeader = req.headers['if-modified-since']; - if (reqModifiedHeader) { - if (reqModifiedHeader === (file.uploadDate && file.uploadDate.toUTCString())) { - res.setHeader('Last-Modified', reqModifiedHeader); - res.writeHead(304); - res.end(); - return; - } - } + res.setHeader('Cache-Control', 'public, max-age=0'); + res.setHeader('Expires', '-1'); - res.setHeader('Cache-Control', 'public, max-age=0'); - res.setHeader('Expires', '-1'); + if (format && format !== file.extension && ['png', 'jpg', 'jpeg'].includes(format)) { + res.setHeader('Content-Type', `image/${format}`); + sharp(file.content) + .toFormat(format as any) + .pipe(res); + return; + } - if (format && format !== file.extension && ['png', 'jpg', 'jpeg'].includes(format)) { - res.setHeader('Content-Type', `image/${format}`); - sharp(file.content).toFormat(format).pipe(res); - return; - } + res.setHeader('Last-Modified', (file.uploadDate && file.uploadDate.toUTCString()) || new Date().toUTCString()); + res.setHeader('Content-Type', file.contentType); + res.setHeader('Content-Length', file.size); + res.writeHead(200); + res.end(file.content); +}); - res.setHeader('Last-Modified', (file.uploadDate && file.uploadDate.toUTCString()) || new Date().toUTCString()); - res.setHeader('Content-Type', file.contentType); - res.setHeader('Content-Length', file.size); - res.writeHead(200); - res.end(file.content); - }), -); +WebApp.connectHandlers.use('/assets/', listener); diff --git a/apps/meteor/app/assets/server/index.js b/apps/meteor/app/assets/server/index.ts similarity index 100% rename from apps/meteor/app/assets/server/index.js rename to apps/meteor/app/assets/server/index.ts diff --git a/apps/meteor/app/favico/client/favico.js b/apps/meteor/app/favico/client/favico.js deleted file mode 100644 index b85f9b4a4ee1..000000000000 --- a/apps/meteor/app/favico/client/favico.js +++ /dev/null @@ -1,844 +0,0 @@ -/** - * @license MIT - * @fileOverview Favico animations - * @author Miroslav Magda, http://blog.ejci.net - * @version 0.3.10 - */ - -/** - * Create new favico instance - * @param {Object} Options - * @return {Object} Favico object - * @example - * var favico = new Favico({ - * bgColor : '#d00', - * textColor : '#fff', - * fontFamily : 'sans-serif', - * fontStyle : 'bold', - * position : 'down', - * type : 'circle', - * animation : 'slide', - * dataUrl: function(url){}, - * win: top - * }); - */ -/* eslint-disable */ - - export const Favico = (function(opt) { - 'use strict'; - opt = (opt) ? opt : {}; - var _def = { - bgColor: '#d00', - textColor: '#fff', - fontFamily: 'sans-serif', //Arial,Verdana,Times New Roman,serif,sans-serif,... - fontStyle: 'bold', //normal,italic,oblique,bold,bolder,lighter,100,200,300,400,500,600,700,800,900 - type: 'circle', - position: 'down', // down, up, left, leftup (upleft) - animation: 'slide', - elementId: false, - dataUrl: false, - win: window - }; - var _opt, _orig, _h, _w, _canvas, _context, _img, _ready, _lastBadge, _running, _readyCb, _stop, _browser, _animTimeout, _drawTimeout, _doc; - - _browser = {}; - _browser.ff = typeof InstallTrigger !== 'undefined'; - _browser.chrome = !!window.chrome; - _browser.opera = !!window.opera || navigator.userAgent.indexOf('Opera') >= 0; - _browser.ie = /*@cc_on!@*/ false; - _browser.safari = Object.prototype.toString.call(window.HTMLElement).indexOf('Constructor') > 0; - _browser.supported = (_browser.chrome || _browser.ff || _browser.opera); - - var _queue = []; - _readyCb = function() {}; - _ready = _stop = false; - /** - * Initialize favico - */ - var init = function() { - //merge initial options - _opt = merge(_def, opt); - _opt.bgColor = hexToRgb(_opt.bgColor); - _opt.textColor = hexToRgb(_opt.textColor); - _opt.position = _opt.position.toLowerCase(); - _opt.animation = (animation.types['' + _opt.animation]) ? _opt.animation : _def.animation; - - _doc = _opt.win.document; - - var isUp = _opt.position.indexOf('up') > -1; - var isLeft = _opt.position.indexOf('left') > -1; - - //transform the animations - if (isUp || isLeft) { - for (var a in animation.types) { - for (var i = 0; i < animation.types[a].length; i++) { - var step = animation.types[a][i]; - - if (isUp) { - if (step.y < 0.6) { - step.y = step.y - 0.4; - } else { - step.y = step.y - 2 * step.y + (1 - step.w); - } - } - - if (isLeft) { - if (step.x < 0.6) { - step.x = step.x - 0.4; - } else { - step.x = step.x - 2 * step.x + (1 - step.h); - } - } - - animation.types[a][i] = step; - } - } - } - _opt.type = (type['' + _opt.type]) ? _opt.type : _def.type; - - _orig = link.getIcons(); - //create temp canvas - _canvas = document.createElement('canvas'); - //create temp image - _img = document.createElement('img'); - var lastIcon = _orig[_orig.length - 1]; - if (lastIcon.hasAttribute('href')) { - _img.setAttribute('crossOrigin', 'anonymous'); - //get width/height - _img.onload = function() { - _h = (_img.height > 0) ? _img.height : 32; - _w = (_img.width > 0) ? _img.width : 32; - _canvas.height = _h; - _canvas.width = _w; - _context = _canvas.getContext('2d'); - icon.ready(); - }; - _img.setAttribute('src', lastIcon.getAttribute('href')); - } else { - _img.onload = function() { - _h = 32; - _w = 32; - _img.height = _h; - _img.width = _w; - _canvas.height = _h; - _canvas.width = _w; - _context = _canvas.getContext('2d'); - icon.ready(); - }; - _img.setAttribute('src', ''); - } - - }; - /** - * Icon namespace - */ - var icon = {}; - /** - * Icon is ready (reset icon) and start animation (if ther is any) - */ - icon.ready = function() { - _ready = true; - icon.reset(); - _readyCb(); - }; - /** - * Reset icon to default state - */ - icon.reset = function() { - //reset - if (!_ready) { - return; - } - _queue = []; - _lastBadge = false; - _running = false; - _context.clearRect(0, 0, _w, _h); - _context.drawImage(_img, 0, 0, _w, _h); - //_stop=true; - link.setIcon(_canvas); - //webcam('stop'); - //video('stop'); - window.clearTimeout(_animTimeout); - window.clearTimeout(_drawTimeout); - }; - /** - * Start animation - */ - icon.start = function() { - if (!_ready || _running) { - return; - } - var finished = function() { - _lastBadge = _queue[0]; - _running = false; - if (_queue.length > 0) { - _queue.shift(); - icon.start(); - } - }; - if (_queue.length > 0) { - _running = true; - var run = function() { - // apply options for this animation - ['type', 'animation', 'bgColor', 'textColor', 'fontFamily', 'fontStyle'].forEach(function(a) { - if (a in _queue[0].options) { - _opt[a] = _queue[0].options[a]; - } - }); - animation.run(_queue[0].options, function() { - finished(); - }, false); - }; - if (_lastBadge) { - animation.run(_lastBadge.options, function() { - run(); - }, true); - } else { - run(); - } - } - }; - - /** - * Badge types - */ - var type = {}; - var options = function(opt) { - opt.n = ((typeof opt.n) === 'number') ? Math.abs(opt.n | 0) : opt.n; - opt.x = _w * opt.x; - opt.y = _h * opt.y; - opt.w = _w * opt.w; - opt.h = _h * opt.h; - opt.len = ('' + opt.n).length; - return opt; - }; - /** - * Generate circle - * @param {Object} opt Badge options - */ - type.circle = function(opt) { - opt = options(opt); - var more = false; - if (opt.len === 2) { - opt.x = opt.x - opt.w * 0.4; - opt.w = opt.w * 1.4; - more = true; - } else if (opt.len >= 3) { - opt.x = opt.x - opt.w * 0.65; - opt.w = opt.w * 1.65; - more = true; - } - _context.clearRect(0, 0, _w, _h); - _context.drawImage(_img, 0, 0, _w, _h); - _context.beginPath(); - _context.font = _opt.fontStyle + ' ' + Math.floor(opt.h * (opt.n > 99 ? 0.85 : 1)) + 'px ' + _opt.fontFamily; - _context.textAlign = 'center'; - if (more) { - _context.moveTo(opt.x + opt.w / 2, opt.y); - _context.lineTo(opt.x + opt.w - opt.h / 2, opt.y); - _context.quadraticCurveTo(opt.x + opt.w, opt.y, opt.x + opt.w, opt.y + opt.h / 2); - _context.lineTo(opt.x + opt.w, opt.y + opt.h - opt.h / 2); - _context.quadraticCurveTo(opt.x + opt.w, opt.y + opt.h, opt.x + opt.w - opt.h / 2, opt.y + opt.h); - _context.lineTo(opt.x + opt.h / 2, opt.y + opt.h); - _context.quadraticCurveTo(opt.x, opt.y + opt.h, opt.x, opt.y + opt.h - opt.h / 2); - _context.lineTo(opt.x, opt.y + opt.h / 2); - _context.quadraticCurveTo(opt.x, opt.y, opt.x + opt.h / 2, opt.y); - } else { - _context.arc(opt.x + opt.w / 2, opt.y + opt.h / 2, opt.h / 2, 0, 2 * Math.PI); - } - _context.fillStyle = 'rgba(' + _opt.bgColor.r + ',' + _opt.bgColor.g + ',' + _opt.bgColor.b + ',' + opt.o + ')'; - _context.fill(); - _context.closePath(); - _context.beginPath(); - _context.stroke(); - _context.fillStyle = 'rgba(' + _opt.textColor.r + ',' + _opt.textColor.g + ',' + _opt.textColor.b + ',' + opt.o + ')'; - //_context.fillText((more) ? '9+' : opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - if ((typeof opt.n) === 'number' && opt.n > 999) { - _context.fillText(((opt.n > 9999) ? 9 : Math.floor(opt.n / 1000)) + 'k+', Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2)); - } else { - _context.fillText(opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - } - _context.closePath(); - }; - /** - * Generate rectangle - * @param {Object} opt Badge options - */ - type.rectangle = function(opt) { - opt = options(opt); - var more = false; - if (opt.len === 2) { - opt.x = opt.x - opt.w * 0.4; - opt.w = opt.w * 1.4; - more = true; - } else if (opt.len >= 3) { - opt.x = opt.x - opt.w * 0.65; - opt.w = opt.w * 1.65; - more = true; - } - _context.clearRect(0, 0, _w, _h); - _context.drawImage(_img, 0, 0, _w, _h); - _context.beginPath(); - _context.font = _opt.fontStyle + ' ' + Math.floor(opt.h * (opt.n > 99 ? 0.9 : 1)) + 'px ' + _opt.fontFamily; - _context.textAlign = 'center'; - _context.fillStyle = 'rgba(' + _opt.bgColor.r + ',' + _opt.bgColor.g + ',' + _opt.bgColor.b + ',' + opt.o + ')'; - _context.fillRect(opt.x, opt.y, opt.w, opt.h); - _context.fillStyle = 'rgba(' + _opt.textColor.r + ',' + _opt.textColor.g + ',' + _opt.textColor.b + ',' + opt.o + ')'; - //_context.fillText((more) ? '9+' : opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - if ((typeof opt.n) === 'number' && opt.n > 999) { - _context.fillText(((opt.n > 9999) ? 9 : Math.floor(opt.n / 1000)) + 'k+', Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.2)); - } else { - _context.fillText(opt.n, Math.floor(opt.x + opt.w / 2), Math.floor(opt.y + opt.h - opt.h * 0.15)); - } - _context.closePath(); - }; - - /** - * Set badge - */ - var badge = function(number, opts) { - opts = ((typeof opts) === 'string' ? { - animation: opts - } : opts) || {}; - _readyCb = function() { - try { - if (typeof(number) === 'number' ? (number > 0) : (number !== '')) { - var q = { - type: 'badge', - options: { - n: number - } - }; - if ('animation' in opts && animation.types['' + opts.animation]) { - q.options.animation = '' + opts.animation; - } - if ('type' in opts && type['' + opts.type]) { - q.options.type = '' + opts.type; - } - ['bgColor', 'textColor'].forEach(function(o) { - if (o in opts) { - q.options[o] = hexToRgb(opts[o]); - } - }); - ['fontStyle', 'fontFamily'].forEach(function(o) { - if (o in opts) { - q.options[o] = opts[o]; - } - }); - _queue.push(q); - if (_queue.length > 100) { - throw new Error('Too many badges requests in queue.'); - } - icon.start(); - } else { - icon.reset(); - } - } catch (e) { - throw new Error('Error setting badge. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - }; - - /** - * Set image as icon - */ - var image = function(imageElement) { - _readyCb = function() { - try { - var w = imageElement.width; - var h = imageElement.height; - var newImg = document.createElement('img'); - var ratio = (w / _w < h / _h) ? (w / _w) : (h / _h); - newImg.setAttribute('crossOrigin', 'anonymous'); - newImg.onload = function() { - _context.clearRect(0, 0, _w, _h); - _context.drawImage(newImg, 0, 0, _w, _h); - link.setIcon(_canvas); - }; - newImg.setAttribute('src', imageElement.getAttribute('src')); - newImg.height = (h / ratio); - newImg.width = (w / ratio); - } catch (e) { - throw new Error('Error setting image. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - }; - /** - * Set video as icon - */ - var video = function(videoElement) { - _readyCb = function() { - try { - if (videoElement === 'stop') { - _stop = true; - icon.reset(); - _stop = false; - return; - } - //var w = videoElement.width; - //var h = videoElement.height; - //var ratio = (w / _w < h / _h) ? (w / _w) : (h / _h); - videoElement.addEventListener('play', function() { - drawVideo(this); - }, false); - - } catch (e) { - throw new Error('Error setting video. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - }; - /** - * Set video as icon - */ - var webcam = function(action) { - //UR - if (!window.URL || !window.URL.createObjectURL) { - window.URL = window.URL || {}; - window.URL.createObjectURL = function(obj) { - return obj; - }; - } - if (_browser.supported) { - var newVideo = false; - navigator.getUserMedia = navigator.getUserMedia || navigator.oGetUserMedia || navigator.msGetUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia; - _readyCb = function() { - try { - if (action === 'stop') { - _stop = true; - icon.reset(); - _stop = false; - return; - } - newVideo = document.createElement('video'); - newVideo.width = _w; - newVideo.height = _h; - navigator.getUserMedia({ - video: true, - audio: false - }, function(stream) { - newVideo.src = URL.createObjectURL(stream); - newVideo.play(); - drawVideo(newVideo); - }, function() {}); - } catch (e) { - throw new Error('Error setting webcam. Message: ' + e.message); - } - }; - if (_ready) { - _readyCb(); - } - } - - }; - - /** - * Draw video to context and repeat :) - */ - function drawVideo(video) { - if (video.paused || video.ended || _stop) { - return false; - } - //nasty hack for FF webcam (Thanks to Julian Ćwirko, kontakt@redsunmedia.pl) - try { - _context.clearRect(0, 0, _w, _h); - _context.drawImage(video, 0, 0, _w, _h); - } catch (e) { - - } - _drawTimeout = setTimeout(function() { - drawVideo(video); - }, animation.duration); - link.setIcon(_canvas); - } - - var link = {}; - /** - * Get icons from HEAD tag or create a new element - */ - link.getIcons = function() { - var elms = []; - //get link element - var getLinks = function() { - var icons = []; - var links = _doc.getElementsByTagName('head')[0].getElementsByTagName('link'); - for (var i = 0; i < links.length; i++) { - if ((/(^|\s)icon(\s|$)/i).test(links[i].getAttribute('rel'))) { - icons.push(links[i]); - } - } - return icons; - }; - if (_opt.element) { - elms = [_opt.element]; - } else if (_opt.elementId) { - //if img element identified by elementId - elms = [_doc.getElementById(_opt.elementId)]; - elms[0].setAttribute('href', elms[0].getAttribute('src')); - } else { - //if link element - elms = getLinks(); - if (elms.length === 0) { - elms = [_doc.createElement('link')]; - elms[0].setAttribute('rel', 'icon'); - _doc.getElementsByTagName('head')[0].appendChild(elms[0]); - } - } - elms.forEach(function(item) { - item.setAttribute('type', 'image/png'); - }); - return elms; - }; - link.setIcon = function(canvas) { - var url = canvas.toDataURL('image/png'); - if (_opt.dataUrl) { - //if using custom exporter - _opt.dataUrl(url); - } - if (_opt.element) { - _opt.element.setAttribute('href', url); - _opt.element.setAttribute('src', url); - } else if (_opt.elementId) { - //if is attached to element (image) - var elm = _doc.getElementById(_opt.elementId); - elm.setAttribute('href', url); - elm.setAttribute('src', url); - } else { - //if is attached to fav icon - if (_browser.ff || _browser.opera) { - //for FF we need to "recreate" element, atach to dom and remove old - //var originalType = _orig.getAttribute('rel'); - var old = _orig[_orig.length - 1]; - var newIcon = _doc.createElement('link'); - _orig = [newIcon]; - //_orig.setAttribute('rel', originalType); - if (_browser.opera) { - newIcon.setAttribute('rel', 'icon'); - } - newIcon.setAttribute('rel', 'icon'); - newIcon.setAttribute('type', 'image/png'); - _doc.getElementsByTagName('head')[0].appendChild(newIcon); - newIcon.setAttribute('href', url); - if (old.parentNode) { - old.parentNode.removeChild(old); - } - } else { - _orig.forEach(function(icon) { - icon.setAttribute('href', url); - }); - } - } - }; - - //http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb#answer-5624139 - //HEX to RGB convertor - function hexToRgb(hex) { - var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; - hex = hex.replace(shorthandRegex, function(m, r, g, b) { - return r + r + g + g + b + b; - }); - var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : false; - } - - /** - * Merge options - */ - function merge(def, opt) { - var mergedOpt = {}; - var attrname; - for (attrname in def) { - mergedOpt[attrname] = def[attrname]; - } - for (attrname in opt) { - mergedOpt[attrname] = opt[attrname]; - } - return mergedOpt; - } - - /** - * Cross-browser page visibility shim - * http://stackoverflow.com/questions/12536562/detect-whether-a-window-is-visible - */ - function isPageHidden() { - return _doc.hidden || _doc.msHidden || _doc.webkitHidden || _doc.mozHidden; - } - - /** - * @namespace animation - */ - var animation = {}; - /** - * Animation "frame" duration - */ - animation.duration = 40; - /** - * Animation types (none,fade,pop,slide) - */ - animation.types = {}; - animation.types.fade = [{ - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.0 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.1 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.2 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.3 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.4 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.5 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.6 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.7 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.8 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 0.9 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1.0 - }]; - animation.types.none = [{ - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - animation.types.pop = [{ - x: 1, - y: 1, - w: 0, - h: 0, - o: 1 - }, { - x: 0.9, - y: 0.9, - w: 0.1, - h: 0.1, - o: 1 - }, { - x: 0.8, - y: 0.8, - w: 0.2, - h: 0.2, - o: 1 - }, { - x: 0.7, - y: 0.7, - w: 0.3, - h: 0.3, - o: 1 - }, { - x: 0.6, - y: 0.6, - w: 0.4, - h: 0.4, - o: 1 - }, { - x: 0.5, - y: 0.5, - w: 0.5, - h: 0.5, - o: 1 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - animation.types.popFade = [{ - x: 0.75, - y: 0.75, - w: 0, - h: 0, - o: 0 - }, { - x: 0.65, - y: 0.65, - w: 0.1, - h: 0.1, - o: 0.2 - }, { - x: 0.6, - y: 0.6, - w: 0.2, - h: 0.2, - o: 0.4 - }, { - x: 0.55, - y: 0.55, - w: 0.3, - h: 0.3, - o: 0.6 - }, { - x: 0.50, - y: 0.50, - w: 0.4, - h: 0.4, - o: 0.8 - }, { - x: 0.45, - y: 0.45, - w: 0.5, - h: 0.5, - o: 0.9 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - animation.types.slide = [{ - x: 0.4, - y: 1, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.9, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.9, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.8, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.7, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.6, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.5, - w: 0.6, - h: 0.6, - o: 1 - }, { - x: 0.4, - y: 0.4, - w: 0.6, - h: 0.6, - o: 1 - }]; - /** - * Run animation - * @param {Object} opt Animation options - * @param {Object} cb Callabak after all steps are done - * @param {Object} revert Reverse order? true|false - * @param {Object} step Optional step number (frame bumber) - */ - animation.run = function(opt, cb, revert, step) { - var animationType = animation.types[isPageHidden() ? 'none' : _opt.animation]; - if (revert === true) { - step = (typeof step !== 'undefined') ? step : animationType.length - 1; - } else { - step = (typeof step !== 'undefined') ? step : 0; - } - cb = (cb) ? cb : function() {}; - if ((step < animationType.length) && (step >= 0)) { - type[_opt.type](merge(opt, animationType[step])); - _animTimeout = setTimeout(function() { - if (revert) { - step = step - 1; - } else { - step = step + 1; - } - animation.run(opt, cb, revert, step); - }, animation.duration); - - link.setIcon(_canvas); - } else { - cb(); - return; - } - }; - //auto init - init(); - return { - badge: badge, - video: video, - image: image, - webcam: webcam, - reset: icon.reset, - browser: { - supported: _browser.supported - } - }; - }); diff --git a/apps/meteor/app/favico/client/index.js b/apps/meteor/app/favico/client/index.js deleted file mode 100644 index 239a252e455c..000000000000 --- a/apps/meteor/app/favico/client/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import { Favico } from './favico'; - -export { Favico }; diff --git a/apps/meteor/app/favico/index.js b/apps/meteor/app/favico/index.js deleted file mode 100644 index 40a7340d3887..000000000000 --- a/apps/meteor/app/favico/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './client/index'; diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.js b/apps/meteor/app/lib/server/functions/getFullUserData.ts similarity index 65% rename from apps/meteor/app/lib/server/functions/getFullUserData.js rename to apps/meteor/app/lib/server/functions/getFullUserData.ts index d328f3b05832..74bf410aee9b 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.js +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -1,7 +1,9 @@ -import { Logger } from '../../../logger'; +import { IUser } from '@rocket.chat/core-typings'; + +import { Logger } from '../../../logger/server'; import { settings } from '../../../settings/server'; import { Users } from '../../../models/server'; -import { hasPermission } from '../../../authorization'; +import { hasPermission } from '../../../authorization/server'; const logger = new Logger('getFullUserData'); @@ -18,7 +20,7 @@ const defaultFields = { statusText: 1, avatarETag: 1, extension: 1, -}; +} as const; const fullFields = { emails: 1, @@ -31,12 +33,12 @@ const fullFields = { requirePasswordChange: 1, requirePasswordChangeReason: 1, roles: 1, -}; +} as const; -let publicCustomFields = {}; -let customFields = {}; +let publicCustomFields: Record = {}; +let customFields: Record = {}; -settings.watch('Accounts_CustomFields', (value) => { +settings.watch('Accounts_CustomFields', (value) => { publicCustomFields = {}; customFields = {}; @@ -58,29 +60,23 @@ settings.watch('Accounts_CustomFields', (value) => { } }); -const getCustomFields = (canViewAllInfo) => (canViewAllInfo ? customFields : publicCustomFields); +const getCustomFields = (canViewAllInfo: boolean): Record => (canViewAllInfo ? customFields : publicCustomFields); -const getFields = (canViewAllInfo) => ({ +const getFields = (canViewAllInfo: boolean): Record => ({ ...defaultFields, ...(canViewAllInfo && fullFields), ...getCustomFields(canViewAllInfo), }); -const removePasswordInfo = (user) => { - if (user && user.services) { - delete user.services.password; - delete user.services.email; - delete user.services.resume; - delete user.services.emailCode; - delete user.services.cloud; - delete user.services.email2fa; - delete user.services.totp; - } - - return user; +const removePasswordInfo = (user: IUser): Omit => { + const { services, ...result } = user; + return result; }; -export function getFullUserDataByIdOrUsername({ userId, filterId, filterUsername }) { +export async function getFullUserDataByIdOrUsername( + userId: string, + { filterId, filterUsername }: { filterId: string; filterUsername?: undefined } | { filterId?: undefined; filterUsername: string }, +): Promise { const caller = Users.findOneById(userId, { fields: { username: 1 } }); const targetUser = filterId || filterUsername; const myself = (filterId && targetUser === userId) || (filterUsername && targetUser === caller.username); diff --git a/apps/meteor/app/lib/server/functions/setUserAvatar.ts b/apps/meteor/app/lib/server/functions/setUserAvatar.ts index e72b847c5cee..4718303b21ca 100644 --- a/apps/meteor/app/lib/server/functions/setUserAvatar.ts +++ b/apps/meteor/app/lib/server/functions/setUserAvatar.ts @@ -8,12 +8,26 @@ import { SystemLogger } from '../../../../server/lib/logger/system'; import { api } from '../../../../server/sdk/api'; import { fetch } from '../../../../server/lib/http/fetch'; -export const setUserAvatar = function ( +export function setUserAvatar( + user: Pick, + dataURI: Buffer, + contentType: string, + service: 'rest', + etag?: string, +): void; +export function setUserAvatar( user: Pick, dataURI: string, contentType: string, service: 'initials' | 'url' | 'rest' | string, etag?: string, +): void; +export function setUserAvatar( + user: Pick, + dataURI: string | Buffer, + contentType: string, + service: 'initials' | 'url' | 'rest' | string, + etag?: string, ): void { if (service === 'initials') { Users.setAvatarData(user._id, service, null); @@ -22,7 +36,7 @@ export const setUserAvatar = function ( const { buffer, type } = Promise.await( (async (): Promise<{ buffer: Buffer; type: string }> => { - if (service === 'url') { + if (service === 'url' && typeof dataURI === 'string') { let response: Response; try { response = await fetch(dataURI); @@ -69,7 +83,7 @@ export const setUserAvatar = function ( if (service === 'rest') { return { - buffer: Buffer.from(dataURI, 'binary'), + buffer: dataURI instanceof Buffer ? dataURI : Buffer.from(dataURI, 'binary'), type: contentType, }; } @@ -103,4 +117,4 @@ export const setUserAvatar = function ( avatarETag, }); }, 500); -}; +} diff --git a/apps/meteor/app/livechat/client/lib/chartHandler.js b/apps/meteor/app/livechat/client/lib/chartHandler.js deleted file mode 100644 index b49d09002fa9..000000000000 --- a/apps/meteor/app/livechat/client/lib/chartHandler.js +++ /dev/null @@ -1,221 +0,0 @@ -import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; - -const lineChartConfiguration = ({ legends = false, anim = false, smallTicks = false, displayColors = false, tooltipCallbacks = {} }) => { - const config = { - layout: { - padding: { - top: 10, - bottom: 0, - }, - }, - legend: { - display: false, - }, - title: { - display: false, - }, - tooltips: { - enabled: true, - mode: 'point', - displayColors, - ...tooltipCallbacks, - }, - scales: { - xAxes: [ - { - scaleLabel: { - display: false, - }, - gridLines: { - display: true, - color: 'rgba(0, 0, 0, 0.03)', - }, - }, - ], - yAxes: [ - { - scaleLabel: { - display: false, - }, - gridLines: { - display: true, - color: 'rgba(0, 0, 0, 0.03)', - }, - ticks: { - beginAtZero: true, - }, - }, - ], - }, - hover: { - animationDuration: 0, // duration of animations when hovering an item - }, - responsive: true, - maintainAspectRatio: false, - responsiveAnimationDuration: 0, // animation duration after a resize - }; - - if (!anim) { - config.animation = { - duration: 0, // general animation time - }; - } - - if (legends) { - config.legend = { - display: true, - labels: { - boxWidth: 20, - fontSize: 8, - }, - }; - } - - if (smallTicks) { - config.scales.xAxes[0].ticks = { - fontSize: 8, - }; - - config.scales.yAxes[0].ticks = { - beginAtZero: true, - fontSize: 8, - }; - } - - return config; -}; - -const doughnutChartConfiguration = (title, tooltipCallbacks = {}) => ({ - layout: { - padding: { - top: 0, - bottom: 0, - }, - }, - legend: { - display: true, - position: 'right', - labels: { - boxWidth: 20, - fontSize: 8, - }, - }, - title: { - display: 'true', - text: title, - }, - tooltips: { - enabled: true, - mode: 'point', - displayColors: false, // hide color box - ...tooltipCallbacks, - }, - // animation: { - // duration: 0 // general animation time - // }, - hover: { - animationDuration: 0, // duration of animations when hovering an item - }, - responsive: true, - maintainAspectRatio: false, - responsiveAnimationDuration: 0, // animation duration after a resize -}); - -/** - * - * @param {Object} chart - chart element - * @param {Object} chartContext - Context of chart - * @param {Array(String)} chartLabel - * @param {Array(String)} dataLabels - * @param {Array(Array(Double))} dataPoints - */ -export const drawLineChart = async (chart, chartContext, chartLabels, dataLabels, dataSets, options = {}) => { - if (!chart) { - console.log('No chart element'); - return; - } - if (chartContext) { - chartContext.destroy(); - } - const colors = ['#2de0a5', '#ffd21f', '#f5455c', '#cbced1']; - - const datasets = []; - - chartLabels.forEach(function (chartLabel, index) { - datasets.push({ - label: TAPi18n.__(chartLabel), // chart label - data: dataSets[index], // data points corresponding to data labels, x-axis points - backgroundColor: [colors[index]], - borderColor: [colors[index]], - borderWidth: 3, - fill: false, - }); - }); - const chartImport = await import('chart.js'); - const Chart = chartImport.default; - return new Chart(chart, { - type: 'line', - data: { - labels: dataLabels, // data labels, y-axis points - datasets, - }, - options: lineChartConfiguration(options), - }); -}; - -/** - * - * @param {Object} chart - chart element - * @param {Object} chartContext - Context of chart - * @param {Array(String)} dataLabels - * @param {Array(Double)} dataPoints - */ -export const drawDoughnutChart = async (chart, title, chartContext, dataLabels, dataPoints) => { - if (!chart) { - return; - } - if (chartContext) { - chartContext.destroy(); - } - const chartImport = await import('chart.js'); - const Chart = chartImport.default; - return new Chart(chart, { - type: 'doughnut', - data: { - labels: dataLabels, // data labels, y-axis points - datasets: [ - { - data: dataPoints, // data points corresponding to data labels, x-axis points - backgroundColor: ['#2de0a5', '#cbced1', '#f5455c', '#ffd21f'], - borderWidth: 0, - }, - ], - }, - options: doughnutChartConfiguration(title), - }); -}; - -/** - * Update chart - * @param {Object} chart [Chart context] - * @param {String} label [chart label] - * @param {Array(Double)} data [updated data] - */ -export const updateChart = async (c, label, data) => { - const chart = await c; - if (chart.data.labels.indexOf(label) === -1) { - // insert data - chart.data.labels.push(label); - chart.data.datasets.forEach((dataset, idx) => { - dataset.data.push(data[idx]); - }); - } else { - // update data - const index = chart.data.labels.indexOf(label); - chart.data.datasets.forEach((dataset, idx) => { - dataset.data[index] = data[idx]; - }); - } - - chart.update(); -}; diff --git a/apps/meteor/app/livechat/client/lib/chartHandler.ts b/apps/meteor/app/livechat/client/lib/chartHandler.ts new file mode 100644 index 000000000000..7f9b6f1a6f4f --- /dev/null +++ b/apps/meteor/app/livechat/client/lib/chartHandler.ts @@ -0,0 +1,232 @@ +import type { ChartItem, Chart as ChartType, ChartConfiguration } from 'chart.js'; +import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; + +type LineChartConfigOptions = Partial<{ + legends: boolean; + anim: boolean; + displayColors: boolean; + tooltipCallbacks: any; +}>; + +const lineChartConfiguration = ({ + legends = false, + anim = false, + tooltipCallbacks = {}, +}: LineChartConfigOptions): Partial['options']> => { + const config: ChartConfiguration<'line', number, string>['options'] = { + layout: { + padding: { + top: 10, + bottom: 0, + }, + }, + legend: { + display: false, + }, + plugins: { + tooltip: { + usePointStyle: true, + enabled: true, + mode: 'point', + yAlign: 'bottom', + displayColors: true, + ...tooltipCallbacks, + }, + }, + scales: { + xAxis: { + title: { + display: false, + }, + grid: { + display: true, + color: 'rgba(0, 0, 0, 0.03)', + }, + }, + yAxis: { + title: { + display: false, + }, + grid: { + display: true, + color: 'rgba(0, 0, 0, 0.03)', + }, + }, + }, + hover: { + intersect: false, // duration of animations when hovering an item + mode: 'index', + }, + responsive: true, + maintainAspectRatio: false, + ...(!anim ? { animation: { duration: 0 } } : {}), + ...(legends ? { legend: { display: true, labels: { boxWidth: 20, fontSize: 8 } } } : {}), + }; + + return config; +}; + +const doughnutChartConfiguration = ( + title: string, + tooltipCallbacks = {}, +): Partial['options']> => ({ + layout: { + padding: { + top: 0, + bottom: 0, + }, + }, + plugins: { + legend: { + display: true, + position: 'right', + labels: { + boxWidth: 20, + }, + }, + title: { + display: true, + text: title, + }, + tooltip: { + enabled: true, + mode: 'point', + displayColors: true, // hide color box + ...tooltipCallbacks, + }, + }, + // animation: { + // duration: 0 // general animation time + // }, + hover: { + intersect: true, // duration of animations when hovering an item + }, + responsive: true, + maintainAspectRatio: false, +}); + +type ChartDataSet = { + label: string; + data: number; + backgroundColor: string; + borderColor: string; + borderWidth: number; + fill: boolean; +}; + +/** + * + * @param {Object} chart - chart element + * @param {Object} chartContext - Context of chart + * @param {Array(String)} chartLabel + * @param {Array(String)} dataLabels + * @param {Array(Array(Double))} dataPoints + */ +export const drawLineChart = async ( + chart: HTMLCanvasElement, + chartContext: { destroy: () => void } | undefined, + chartLabels: string[], + dataLabels: string[], + dataSets: number[], + options: LineChartConfigOptions = {}, +): Promise | void> => { + if (!chart) { + console.error('No chart element'); + return; + } + if (chartContext) { + chartContext.destroy(); + } + const colors = ['#2de0a5', '#ffd21f', '#f5455c', '#cbced1']; + + const datasets: ChartDataSet[] = []; + + chartLabels.forEach(function (chartLabel: string, index: number) { + datasets.push({ + label: TAPi18n.__(chartLabel), // chart label + data: dataSets[index], // data points corresponding to data labels, x-axis points + backgroundColor: colors[index], + borderColor: colors[index], + borderWidth: 3, + fill: false, + }); + }); + const chartjs = await import('chart.js/auto'); + const Chart = chartjs.default; + return new Chart(chart, { + type: 'line', + data: { + labels: dataLabels, // data labels, y-axis points + datasets, + }, + options: lineChartConfiguration(options), + }); +}; + +/** + * + * @param {Object} chart - chart element + * @param {Object} chartContext - Context of chart + * @param {Array(String)} dataLabels + * @param {Array(Double)} dataPoints + */ +export const drawDoughnutChart = async ( + chart: ChartItem, + title: string, + chartContext: { destroy: () => void } | undefined, + dataLabels: string[], + dataPoints: number[], +): Promise | void> => { + if (!chart) { + console.error('No chart element'); + return; + } + if (chartContext) { + chartContext.destroy(); + } + const chartjs = await import('chart.js/auto'); + const Chart = chartjs.default; + return new Chart(chart, { + type: 'doughnut', + data: { + labels: dataLabels, // data labels, y-axis points + datasets: [ + { + data: dataPoints, // data points corresponding to data labels, x-axis points + backgroundColor: ['#2de0a5', '#cbced1', '#f5455c', '#ffd21f'], + borderWidth: 0, + }, + ], + }, + options: doughnutChartConfiguration(title), + }); +}; + +/** + * Update chart + * @param {Object} chart [Chart context] + * @param {String} label [chart label] + * @param {Array(Double)} data [updated data] + */ +export const updateChart = async (c: ChartType, label: string, data: { [x: string]: number }): Promise => { + const chart = await c; + if (chart.data?.labels?.indexOf(label) === -1) { + // insert data + chart.data.labels.push(label); + chart.data.datasets.forEach((dataset: { data: any[] }, idx: string | number) => { + dataset.data.push(data[idx]); + }); + } else { + // update data + const index = chart.data?.labels?.indexOf(label); + if (typeof index === 'undefined') { + return; + } + + chart.data.datasets.forEach((dataset: { data: { [x: string]: any } }, idx: string | number) => { + dataset.data[index] = data[idx]; + }); + } + + chart.update(); +}; diff --git a/apps/meteor/app/models/server/raw/Users.js b/apps/meteor/app/models/server/raw/Users.js index 97b6da84167e..b0ea06bf1028 100644 --- a/apps/meteor/app/models/server/raw/Users.js +++ b/apps/meteor/app/models/server/raw/Users.js @@ -162,6 +162,15 @@ export class UsersRaw extends BaseRaw { return this.find(query, options); } + findActiveByIdsOrUsernames(userIds, options = {}) { + const query = { + $or: [{ _id: { $in: userIds } }, { username: { $in: userIds } }], + active: true, + }; + + return this.find(query, options); + } + findByIds(userIds, options = {}) { const query = { _id: { $in: userIds }, diff --git a/apps/meteor/app/settings/server/SettingsRegistry.ts b/apps/meteor/app/settings/server/SettingsRegistry.ts index d6799596ed9a..0c5c00ff2c10 100644 --- a/apps/meteor/app/settings/server/SettingsRegistry.ts +++ b/apps/meteor/app/settings/server/SettingsRegistry.ts @@ -135,8 +135,10 @@ export class SettingsRegistry { throw new Error(`Enterprise setting ${_id} is missing the invalidValue option`); } + const settingFromCodeOverwritten = overwriteSetting(settingFromCode); + const settingStored = this.store.getSetting(_id); - const settingOverwritten = overwriteSetting(settingFromCode); + const settingStoredOverwritten = settingStored && overwriteSetting(settingStored); try { validateSetting(settingFromCode._id, settingFromCode.type, settingFromCode.value); @@ -144,14 +146,14 @@ export class SettingsRegistry { IS_DEVELOPMENT && SystemLogger.error(`Invalid setting code ${_id}: ${(e as Error).message}`); } - const isOverwritten = settingFromCode !== settingOverwritten; + const isOverwritten = settingFromCode !== settingFromCodeOverwritten || (settingStored && settingStored !== settingStoredOverwritten); - const { _id: _, ...settingProps } = settingOverwritten; + const { _id: _, ...settingProps } = settingFromCodeOverwritten; - if (settingStored && !compareSettings(settingStored, settingOverwritten)) { - const { value: _value, ...settingOverwrittenProps } = settingOverwritten; + if (settingStored && !compareSettings(settingStored, settingFromCodeOverwritten)) { + const { value: _value, ...settingOverwrittenProps } = settingFromCodeOverwritten; - const overwrittenKeys = Object.keys(settingOverwritten); + const overwrittenKeys = Object.keys(settingFromCodeOverwritten); const removedKeys = Object.keys(settingStored).filter((key) => !['_updatedAt'].includes(key) && !overwrittenKeys.includes(key)); this.model.upsert( @@ -168,7 +170,7 @@ export class SettingsRegistry { } if (settingStored && isOverwritten) { - if (settingStored.value !== settingOverwritten.value) { + if (settingStored.value !== settingFromCodeOverwritten.value) { this.model.upsert({ _id }, settingProps); } return; @@ -185,7 +187,7 @@ export class SettingsRegistry { const settingOverwrittenDefault = overrideSetting(settingFromCode); - const setting = isOverwritten ? settingOverwritten : settingOverwrittenDefault; + const setting = isOverwritten ? settingFromCodeOverwritten : settingOverwrittenDefault; this.model.insert(setting); // no need to emit unless we remove the oplog diff --git a/apps/meteor/app/utils/lib/mimeTypes.js b/apps/meteor/app/utils/lib/mimeTypes.js deleted file mode 100644 index 70cd99776e43..000000000000 --- a/apps/meteor/app/utils/lib/mimeTypes.js +++ /dev/null @@ -1,8 +0,0 @@ -import mime from 'mime-type/with-db'; - -mime.types.wav = 'audio/wav'; -mime.define('image/vnd.microsoft.icon', { extensions: ['ico'] }, mime.dupAppend); -mime.define('image/x-icon', { extensions: ['ico'] }, mime.dupAppend); -mime.types.ico = 'image/x-icon'; - -export { mime }; diff --git a/apps/meteor/app/utils/lib/mimeTypes.ts b/apps/meteor/app/utils/lib/mimeTypes.ts new file mode 100644 index 000000000000..dd166f17bed3 --- /dev/null +++ b/apps/meteor/app/utils/lib/mimeTypes.ts @@ -0,0 +1,14 @@ +import mime from 'mime-type/with-db'; + +mime.types.wav = 'audio/wav'; +mime.define('image/vnd.microsoft.icon', { source: '', extensions: ['ico'] }, mime.dupAppend); +mime.define('image/x-icon', { source: '', extensions: ['ico'] }, mime.dupAppend); +mime.types.ico = 'image/x-icon'; + +const getExtension = (param: string): string => { + const extension = mime.extension(param); + + return !extension || typeof extension === 'boolean' ? '' : extension; +}; + +export { mime, getExtension }; diff --git a/apps/meteor/client/components/Header/Header.stories.tsx b/apps/meteor/client/components/Header/Header.stories.tsx index 0f7905c3d0a6..a8742be2b807 100644 --- a/apps/meteor/client/components/Header/Header.stories.tsx +++ b/apps/meteor/client/components/Header/Header.stories.tsx @@ -30,8 +30,9 @@ export default { value={{ hasPrivateAccess: true, isLoading: false, - querySetting: (_id) => ({ - getCurrentValue: () => ({ + querySetting: (_id) => [ + () => () => undefined, + () => ({ _id, type: 'action', value: '', @@ -44,12 +45,8 @@ export default { sorter: 1, ts: new Date(), }), - subscribe: () => () => undefined, - }), - querySettings: () => ({ - getCurrentValue: () => [], - subscribe: () => () => undefined, - }), + ], + querySettings: () => [() => () => undefined, () => []], dispatch: async () => undefined, }} > diff --git a/apps/meteor/client/components/Omnichannel/Tags.tsx b/apps/meteor/client/components/Omnichannel/Tags.tsx index ad2d515f4373..6887bfe07e4b 100644 --- a/apps/meteor/client/components/Omnichannel/Tags.tsx +++ b/apps/meteor/client/components/Omnichannel/Tags.tsx @@ -2,11 +2,10 @@ import { Field, TextInput, Chip, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts'; import React, { ChangeEvent, ReactElement, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import { AsyncStatePhase } from '../../hooks/useAsyncState'; import { useEndpointData } from '../../hooks/useEndpointData'; -import { formsSubscription } from '../../views/omnichannel/additionalForms'; +import { useFormsSubscription } from '../../views/omnichannel/additionalForms'; import { FormSkeleton } from './Skeleton'; const Tags = ({ @@ -21,7 +20,7 @@ const Tags = ({ tagRequired?: boolean; }): ReactElement => { const t = useTranslation(); - const forms = useSubscription(formsSubscription); + const forms = useFormsSubscription() as any; const { value: tagsResult, phase: stateTags } = useEndpointData('/v1/livechat/tags.list'); diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx index 7c825e1d54f9..b51a962d8e92 100644 --- a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx +++ b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/context/OmnichannelRoomIconContext.tsx @@ -1,31 +1,26 @@ import { createContext, useMemo, useContext } from 'react'; -import { useSubscription, Unsubscribe } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { AsyncState } from '../../../../lib/asyncState/AsyncState'; import { AsyncStatePhase } from '../../../../lib/asyncState/AsyncStatePhase'; type IOmnichannelRoomIconContext = { - queryIcon( - app: string, - icon: string, - ): { - getCurrentValue: () => AsyncState; - subscribe: (callback: () => void) => Unsubscribe; - }; + queryIcon(app: string, icon: string): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => AsyncState]; }; export const OmnichannelRoomIconContext = createContext({ - queryIcon: () => ({ - getCurrentValue: (): AsyncState => ({ + queryIcon: () => [ + (): (() => void) => (): void => undefined, + (): AsyncState => ({ phase: AsyncStatePhase.LOADING, value: undefined, error: undefined, }), - subscribe: (): Unsubscribe => (): void => undefined, - }), + ], }); export const useOmnichannelRoomIcon = (app: string, icon: string): AsyncState => { const { queryIcon } = useContext(OmnichannelRoomIconContext); - return useSubscription(useMemo(() => queryIcon(app, icon), [app, queryIcon, icon])); + const [subscribe, getSnapshot] = useMemo(() => queryIcon(app, icon), [app, queryIcon, icon]); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx index 0e3ca29581d3..3171ebf34759 100644 --- a/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx +++ b/apps/meteor/client/components/RoomIcon/OmnichannelRoomIcon/provider/OmnichannelRoomIconProvider.tsx @@ -1,28 +1,37 @@ -import React, { FC, useMemo } from 'react'; +import React, { FC, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import { useSubscription, Subscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { AsyncState } from '../../../../lib/asyncState/AsyncState'; import { AsyncStatePhase } from '../../../../lib/asyncState/AsyncStatePhase'; import { OmnichannelRoomIconContext } from '../context/OmnichannelRoomIconContext'; import OmnichannelRoomIcon from '../lib/OmnichannelRoomIcon'; +let icons = Array.from(OmnichannelRoomIcon.icons.values()); + export const OmnichannelRoomIconProvider: FC = ({ children }) => { - const svgIcons = useSubscription( - useMemo( - () => ({ - getCurrentValue: (): string[] => Array.from(OmnichannelRoomIcon.icons.values()), - subscribe: (callback): (() => void) => OmnichannelRoomIcon.on('change', callback), - }), + const svgIcons = useSyncExternalStore( + useCallback( + (callback): (() => void) => + OmnichannelRoomIcon.on('change', () => { + icons = Array.from(OmnichannelRoomIcon.icons.values()); + callback(); + }), [], ), + (): string[] => icons, ); + return ( ({ - queryIcon: (app: string, iconName: string): Subscription> => ({ - getCurrentValue: (): AsyncState => { + queryIcon: ( + app: string, + iconName: string, + ): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => AsyncState] => [ + (callback): (() => void) => OmnichannelRoomIcon.on(`${app}-${iconName}`, callback), + (): AsyncState => { const icon = OmnichannelRoomIcon.get(app, iconName); if (!icon) { @@ -39,8 +48,7 @@ export const OmnichannelRoomIconProvider: FC = ({ children }) => { error: undefined, }; }, - subscribe: (callback): (() => void) => OmnichannelRoomIcon.on(`${app}-${iconName}`, callback), - }), + ], }), [], )} diff --git a/apps/meteor/client/components/VerticalBar/VerticalBarActionBack.tsx b/apps/meteor/client/components/VerticalBar/VerticalBarActionBack.tsx deleted file mode 100644 index 427dbd1d7fc6..000000000000 --- a/apps/meteor/client/components/VerticalBar/VerticalBarActionBack.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React, { ReactElement, memo, ComponentProps } from 'react'; - -import VerticalBarAction from './VerticalBarAction'; - -const VerticalBarActionBack = (props: ComponentProps): ReactElement => ( - -); - -export default memo(VerticalBarActionBack); diff --git a/apps/meteor/client/components/VerticalBar/VerticalBarBack.tsx b/apps/meteor/client/components/VerticalBar/VerticalBarBack.tsx new file mode 100644 index 000000000000..ecfcc9716be2 --- /dev/null +++ b/apps/meteor/client/components/VerticalBar/VerticalBarBack.tsx @@ -0,0 +1,13 @@ +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { ReactElement, memo, ComponentProps } from 'react'; + +import VerticalBarAction from './VerticalBarAction'; + +type VerticalBarBackProps = Partial>; + +const VerticalBarBack = (props: VerticalBarBackProps): ReactElement => { + const t = useTranslation(); + return ; +}; + +export default memo(VerticalBarBack); diff --git a/apps/meteor/client/components/VerticalBar/index.ts b/apps/meteor/client/components/VerticalBar/index.ts index 9d5d37e9246d..e970ab61915a 100644 --- a/apps/meteor/client/components/VerticalBar/index.ts +++ b/apps/meteor/client/components/VerticalBar/index.ts @@ -1,7 +1,7 @@ import VerticalBar from './VerticalBar'; import VerticalBarAction from './VerticalBarAction'; -import VerticalBarActionBack from './VerticalBarActionBack'; import VerticalBarActions from './VerticalBarActions'; +import VerticalBarBack from './VerticalBarBack'; import VerticalBarButton from './VerticalBarButton'; import VerticalBarClose from './VerticalBarClose'; import VerticalBarContent from './VerticalBarContent'; @@ -26,5 +26,5 @@ export default Object.assign(VerticalBar, { ScrollableContent: VerticalBarScrollableContent, Skeleton: VerticalBarSkeleton, Button: VerticalBarButton, - Back: VerticalBarActionBack, + Back: VerticalBarBack, }); diff --git a/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx b/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx index 104fd5427070..31a4d07a294f 100644 --- a/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx +++ b/apps/meteor/client/components/voip/modal/WrapUpCallModal.tsx @@ -3,7 +3,6 @@ import { useSetModal, useTranslation } from '@rocket.chat/ui-contexts'; import React, { ReactElement, useEffect } from 'react'; import { useForm, SubmitHandler } from 'react-hook-form'; -import { useCallCloseRoom } from '../../../contexts/CallContext'; import Tags from '../../Omnichannel/Tags'; type WrapUpCallPayload = { @@ -11,9 +10,12 @@ type WrapUpCallPayload = { tags?: string[]; }; -export const WrapUpCallModal = (): ReactElement => { +type WrapUpCallModalProps = { + closeRoom: (data?: { comment?: string; tags?: string[] }) => void; +}; + +export const WrapUpCallModal = ({ closeRoom }: WrapUpCallModalProps): ReactElement => { const setModal = useSetModal(); - const closeRoom = useCallCloseRoom(); const closeModal = (): void => setModal(null); const t = useTranslation(); diff --git a/apps/meteor/client/contexts/CallContext.ts b/apps/meteor/client/contexts/CallContext.ts index f5c02d0292a9..b28a325e97cf 100644 --- a/apps/meteor/client/contexts/CallContext.ts +++ b/apps/meteor/client/contexts/CallContext.ts @@ -1,7 +1,7 @@ import type { IVoipRoom } from '@rocket.chat/core-typings'; import { ICallerInfo, VoIpCallerInfo } from '@rocket.chat/core-typings'; -import { createContext, useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { createContext, useCallback, useContext } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { VoIPUser } from '../lib/voip/VoIPUser'; @@ -62,10 +62,16 @@ export const useIsCallEnabled = (): boolean => { return enabled; }; +let callerInfo: VoIpCallerInfo; + export const useIsCallReady = (): boolean => { - const { ready } = useContext(CallContext); + const context = useContext(CallContext); + + if (isCallContextReady(context)) { + callerInfo = context.voipClient.callerInfo; + } - return Boolean(ready); + return !!context.ready; }; export const useIsCallError = (): boolean => { @@ -89,20 +95,20 @@ export const useCallerInfo = (): VoIpCallerInfo => { throw new Error('useCallerInfo only if Calls are enabled and ready'); } const { voipClient } = context; - const subscription = useMemo( - () => ({ - getCurrentValue: (): VoIpCallerInfo => voipClient.callerInfo, - subscribe: (callback: () => void): (() => void) => { - voipClient.on('stateChanged', callback); - - return (): void => { - voipClient.off('stateChanged', callback); - }; - }, - }), + const subscribe = useCallback( + (callback: () => void): (() => void) => { + voipClient.on('stateChanged', callback); + + return (): void => { + voipClient.off('stateChanged', callback); + }; + }, [voipClient], ); - return useSubscription(subscription); + + const getSnapshot = (): VoIpCallerInfo => callerInfo; + + return useSyncExternalStore(subscribe, getSnapshot); }; export const useCallCreateRoom = (): CallContextReady['createRoom'] => { diff --git a/apps/meteor/client/hooks/usePresence.ts b/apps/meteor/client/hooks/usePresence.ts index e02fd9c2cc32..488ae58cab86 100644 --- a/apps/meteor/client/hooks/usePresence.ts +++ b/apps/meteor/client/hooks/usePresence.ts @@ -1,5 +1,5 @@ -import { useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useCallback } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { Presence, UserPresence } from '../lib/presence'; @@ -13,18 +13,17 @@ type Presence = 'online' | 'offline' | 'busy' | 'away' | 'loading'; * @public */ export const usePresence = (uid: string | undefined): UserPresence | undefined => { - const subscription = useMemo( - () => ({ - getCurrentValue: (): UserPresence | undefined => (uid ? Presence.store.get(uid) : undefined), - subscribe: (callback: any): any => { - uid && Presence.listen(uid, callback); - return (): void => { - uid && Presence.stop(uid, callback); - }; - }, - }), + const subscribe = useCallback( + (callback: any): any => { + uid && Presence.listen(uid, callback); + return (): void => { + uid && Presence.stop(uid, callback); + }; + }, [uid], ); - return useSubscription(subscription); + const getSnapshot = (): UserPresence | undefined => (uid ? Presence.store.get(uid) : undefined); + + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/hooks/useReactiveValue.ts b/apps/meteor/client/hooks/useReactiveValue.ts index 1ffb609429d3..8c50037121c1 100644 --- a/apps/meteor/client/hooks/useReactiveValue.ts +++ b/apps/meteor/client/hooks/useReactiveValue.ts @@ -1,10 +1,10 @@ import { Tracker } from 'meteor/tracker'; import { useMemo } from 'react'; -import { Subscription, Unsubscribe, useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; export const useReactiveValue = (computeCurrentValue: () => T): T => { - const subscription: Subscription = useMemo(() => { - const callbacks = new Set(); + const [subscribe, getSnapshot] = useMemo(() => { + const callbacks = new Set<() => void>(); let currentValue: T; @@ -15,9 +15,8 @@ export const useReactiveValue = (computeCurrentValue: () => T): T => { }); }); - return { - getCurrentValue: (): T => currentValue, - subscribe: (callback): Unsubscribe => { + return [ + (callback: () => void): (() => void) => { callbacks.add(callback); return (): void => { @@ -28,8 +27,9 @@ export const useReactiveValue = (computeCurrentValue: () => T): T => { } }; }, - }; + (): T => currentValue, + ]; }, [computeCurrentValue]); - return useSubscription(subscription); + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/hooks/useUserData.ts b/apps/meteor/client/hooks/useUserData.ts index 15e2e040741d..4621cf895122 100644 --- a/apps/meteor/client/hooks/useUserData.ts +++ b/apps/meteor/client/hooks/useUserData.ts @@ -1,5 +1,5 @@ -import { useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useCallback } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { UserPresence, Presence } from '../lib/presence'; @@ -11,18 +11,17 @@ import { UserPresence, Presence } from '../lib/presence'; * @public */ export const useUserData = (uid: string): UserPresence | undefined => { - const subscription = useMemo( - () => ({ - getCurrentValue: (): UserPresence | undefined => Presence.store.get(uid), - subscribe: (callback: any): any => { - Presence.listen(uid, callback); - return (): void => { - Presence.stop(uid, callback); - }; - }, - }), + const subscription = useCallback( + (callback: () => void): (() => void) => { + Presence.listen(uid, callback); + return (): void => { + Presence.stop(uid, callback); + }; + }, [uid], ); - return useSubscription(subscription); + const getSnapshot = (): UserPresence | undefined => Presence.store.get(uid); + + return useSyncExternalStore(subscription, getSnapshot); }; diff --git a/apps/meteor/client/importPackages.ts b/apps/meteor/client/importPackages.ts index 554c3e280396..08c95cf98ea1 100644 --- a/apps/meteor/client/importPackages.ts +++ b/apps/meteor/client/importPackages.ts @@ -14,7 +14,6 @@ import '../app/drupal/client'; import '../app/emoji/client'; import '../app/emoji-emojione/client'; import '../app/emoji-custom/client'; -import '../app/favico'; import '../app/file-upload'; import '../app/github-enterprise/client'; import '../app/gitlab/client'; diff --git a/apps/meteor/client/lib/RoomManager.ts b/apps/meteor/client/lib/RoomManager.ts index 31a0d5f0b78a..f05bde279d42 100644 --- a/apps/meteor/client/lib/RoomManager.ts +++ b/apps/meteor/client/lib/RoomManager.ts @@ -1,8 +1,8 @@ import type { IRoom } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { useUserId, useUserRoom, useUserSubscription } from '@rocket.chat/ui-contexts'; -import { useEffect, useMemo } from 'react'; -import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; +import { useCallback, useEffect } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { RoomHistoryManager } from '../../app/ui-utils/client/lib/RoomHistoryManager'; import { useAsyncState } from '../hooks/useAsyncState'; @@ -129,19 +129,15 @@ export const RoomManager = new (class RoomManager extends Emitter<{ } })(); -const subscribeVisitedRooms: Subscription = { - getCurrentValue: () => RoomManager.visitedRooms(), - subscribe(callback) { - return RoomManager.on('changed', callback); - }, -}; +const subscribeVisitedRooms = [ + (callback: () => void): (() => void) => RoomManager.on('changed', callback), + (): IRoom['_id'][] => RoomManager.visitedRooms(), +] as const; -const subscribeOpenedRoom: Subscription = { - getCurrentValue: () => RoomManager.opened, - subscribe(callback) { - return RoomManager.on('opened', callback); - }, -}; +const subscribeOpenedRoom = [ + (callback: () => void): (() => void) => RoomManager.on('opened', callback), + (): IRoom['_id'] | undefined => RoomManager.opened, +] as const; const fields = {}; @@ -165,25 +161,19 @@ export const useHandleRoom = (rid: IRoom['_id']): AsyncState return state; }; -export const useVisitedRooms = (): IRoom['_id'][] => useSubscription(subscribeVisitedRooms); +export const useVisitedRooms = (): IRoom['_id'][] => useSyncExternalStore(...subscribeVisitedRooms); -export const useOpenedRoom = (): IRoom['_id'] | undefined => useSubscription(subscribeOpenedRoom); +export const useOpenedRoom = (): IRoom['_id'] | undefined => useSyncExternalStore(...subscribeOpenedRoom); export const useRoomStore = (rid: IRoom['_id']): RoomStore => { - const subscribeStore: Subscription = useMemo( - () => ({ - getCurrentValue: (): RoomStore | undefined => RoomManager.getStore(rid), - subscribe(callback): Unsubscribe { - return RoomManager.on('changed', callback); - }, - }), - [rid], - ); - - const store = useSubscription(subscribeStore); + const subscribe = useCallback((callback: () => void): (() => void) => RoomManager.on('changed', callback), []); + const getSnapshot = (): RoomStore | undefined => RoomManager.getStore(rid); + + const store = useSyncExternalStore(subscribe, getSnapshot); if (!store) { throw new Error('Something wrong'); } + return store; }; diff --git a/apps/meteor/client/lib/appLayout.ts b/apps/meteor/client/lib/appLayout.ts index 4f2f3a519b7b..a8a8be725401 100644 --- a/apps/meteor/client/lib/appLayout.ts +++ b/apps/meteor/client/lib/appLayout.ts @@ -1,15 +1,14 @@ import { Emitter } from '@rocket.chat/emitter'; import { ReactElement } from 'react'; -import { Subscription, Unsubscribe } from 'use-subscription'; type AppLayoutDescriptor = ReactElement | null; -class AppLayoutSubscription extends Emitter<{ update: void }> implements Subscription { +class AppLayoutSubscription extends Emitter<{ update: void }> { private descriptor: AppLayoutDescriptor = null; - getCurrentValue = (): AppLayoutDescriptor => this.descriptor; + getSnapshot = (): AppLayoutDescriptor => this.descriptor; - subscribe = (callback: () => void): Unsubscribe => this.on('update', callback); + subscribe = (onStoreChange: () => void): (() => void) => this.on('update', onStoreChange); setCurrentValue(descriptor: AppLayoutDescriptor): void { this.descriptor = descriptor; diff --git a/apps/meteor/client/lib/banners.ts b/apps/meteor/client/lib/banners.ts index eb8a06850fa9..02df87e95eca 100644 --- a/apps/meteor/client/lib/banners.ts +++ b/apps/meteor/client/lib/banners.ts @@ -2,7 +2,6 @@ import { UiKitBannerPayload } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { Icon } from '@rocket.chat/fuselage'; import { ComponentProps } from 'react'; -import { Subscription } from 'use-subscription'; export type LegacyBannerPayload = { id: string; @@ -27,10 +26,10 @@ const emitter = new Emitter<{ 'update-first': undefined; }>(); -export const firstSubscription: Subscription = { - getCurrentValue: () => queue[0] ?? null, - subscribe: (callback) => emitter.on('update-first', callback), -}; +export const firstSubscription = [ + (callback: () => void): (() => void) => emitter.on('update-first', callback), + (): BannerPayload | null => queue[0] ?? null, +] as const; export const open = (payload: BannerPayload): void => { let index = queue.findIndex((_payload) => { diff --git a/apps/meteor/client/lib/createSidebarItems.ts b/apps/meteor/client/lib/createSidebarItems.ts index 8b1c7db9e928..b773e733a586 100644 --- a/apps/meteor/client/lib/createSidebarItems.ts +++ b/apps/meteor/client/lib/createSidebarItems.ts @@ -1,5 +1,4 @@ import { IconProps } from '@rocket.chat/fuselage'; -import type { Subscription } from 'use-subscription'; export type SidebarItem = { i18nLabel: string; @@ -17,19 +16,19 @@ export const createSidebarItems = ( ): { registerSidebarItem: (item: SidebarItem) => void; unregisterSidebarItem: (i18nLabel: SidebarItem['i18nLabel']) => void; - itemsSubscription: Subscription; + getSidebarItems: () => SidebarItem[]; + subscribeToSidebarItems: (callback: () => void) => () => void; } => { const items = initialItems; let updateCb: () => void = () => undefined; - const itemsSubscription: Subscription = { - subscribe: (cb) => { - updateCb = cb; - return (): void => { - updateCb = (): void => undefined; - }; - }, - getCurrentValue: () => items, + const getSidebarItems = (): SidebarItem[] => items; + + const subscribeToSidebarItems = (cb: () => void): (() => void) => { + updateCb = cb; + return (): void => { + updateCb = (): void => undefined; + }; }; const registerSidebarItem = (item: SidebarItem): void => { @@ -46,6 +45,7 @@ export const createSidebarItems = ( return { registerSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems, + subscribeToSidebarItems, }; }; diff --git a/apps/meteor/client/lib/createValueSubscription.ts b/apps/meteor/client/lib/createValueSubscription.ts deleted file mode 100644 index 9702f748e93a..000000000000 --- a/apps/meteor/client/lib/createValueSubscription.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Emitter } from '@rocket.chat/emitter'; -import { Subscription, Unsubscribe } from 'use-subscription'; - -type ValueSubscription = Subscription & { - setCurrentValue: (value: T) => void; -}; - -export const createValueSubscription = (initialValue: T): ValueSubscription => { - let value: T = initialValue; - const emitter = new Emitter<{ - update: undefined; - }>(); - - return { - getCurrentValue: (): T => value, - setCurrentValue: (_value: T): void => { - value = _value; - emitter.emit('update'); - }, - subscribe: (callback): Unsubscribe => emitter.on('update', callback), - }; -}; diff --git a/apps/meteor/client/lib/portals/blazePortals.ts b/apps/meteor/client/lib/portals/blazePortals.ts index 0ddf73026744..ab30c96b634b 100644 --- a/apps/meteor/client/lib/portals/blazePortals.ts +++ b/apps/meteor/client/lib/portals/blazePortals.ts @@ -1,25 +1,27 @@ import { Emitter } from '@rocket.chat/emitter'; import { Random } from 'meteor/random'; import type { ReactNode } from 'react'; -import type { Subscription, Unsubscribe } from 'use-subscription'; type BlazePortalEntry = { key: string; node: ReactNode; }; -class BlazePortalsSubscriptions extends Emitter<{ update: void }> implements Subscription { +class BlazePortalsSubscriptions extends Emitter<{ update: void }> { private map = new Map(); - getCurrentValue = (): BlazePortalEntry[] => Array.from(this.map.values()); + private cache = Array.from(this.map.values()); - subscribe = (callback: () => void): Unsubscribe => this.on('update', callback); + getSnapshot = (): BlazePortalEntry[] => this.cache; + + subscribe = (onStoreChange: () => void): (() => void) => this.on('update', onStoreChange); register = (template: Blaze.TemplateInstance, node: ReactNode): void => { const entry = this.map.get(template); if (!entry) { this.map.set(template, { key: Random.id(), node }); + this.cache = Array.from(this.map.values()); this.emit('update'); return; } @@ -29,11 +31,13 @@ class BlazePortalsSubscriptions extends Emitter<{ update: void }> implements Sub } this.map.set(template, { ...entry, node }); + this.cache = Array.from(this.map.values()); this.emit('update'); }; unregister = (template: Blaze.TemplateInstance): void => { if (this.map.delete(template)) { + this.cache = Array.from(this.map.values()); this.emit('update'); } }; diff --git a/apps/meteor/client/lib/portals/portalsSubscription.ts b/apps/meteor/client/lib/portals/portalsSubscription.ts index c17af505c4fc..b72aa8f3ded6 100644 --- a/apps/meteor/client/lib/portals/portalsSubscription.ts +++ b/apps/meteor/client/lib/portals/portalsSubscription.ts @@ -1,14 +1,15 @@ import { Emitter } from '@rocket.chat/emitter'; import { Random } from 'meteor/random'; import type { ReactElement } from 'react'; -import type { Subscription, Unsubscribe } from 'use-subscription'; type SubscribedPortal = { portal: ReactElement; key: string; }; -type PortalsSubscription = Subscription & { +type PortalsSubscription = { + subscribe: (callback: () => void) => () => void; + getSnapshot: () => SubscribedPortal[]; has: (key: unknown) => boolean; set: (key: unknown, portal: ReactElement) => void; delete: (key: unknown) => void; @@ -16,17 +17,20 @@ type PortalsSubscription = Subscription & { const createPortalsSubscription = (): PortalsSubscription => { const portalsMap = new Map(); + let portals = Array.from(portalsMap.values()); const emitter = new Emitter<{ update: void }>(); return { - getCurrentValue: (): SubscribedPortal[] => Array.from(portalsMap.values()), - subscribe: (callback): Unsubscribe => emitter.on('update', callback), + getSnapshot: (): SubscribedPortal[] => portals, + subscribe: (callback): (() => void) => emitter.on('update', callback), delete: (key): void => { portalsMap.delete(key); + portals = Array.from(portalsMap.values()); emitter.emit('update'); }, set: (key, portal): void => { portalsMap.set(key, { portal, key: Random.id() }); + portals = Array.from(portalsMap.values()); emitter.emit('update'); }, has: (key): boolean => portalsMap.has(key), diff --git a/apps/meteor/client/lib/presence.ts b/apps/meteor/client/lib/presence.ts index 5c81820e7e4e..4a14c1478b28 100644 --- a/apps/meteor/client/lib/presence.ts +++ b/apps/meteor/client/lib/presence.ts @@ -27,11 +27,6 @@ export type UserPresence = Readonly< Partial> & Required> >; -type UsersPresencePayload = { - users: UserPresence[]; - full: boolean; -}; - const isUid = (eventType: keyof Events): eventType is UserPresence['_id'] => Boolean(eventType) && typeof eventType === 'string' && !['reset', 'restart', 'remove'].includes(eventType); @@ -51,15 +46,6 @@ const notify = (presence: UserPresence): void => { } }; -declare module '@rocket.chat/rest-typings' { - // eslint-disable-next-line @typescript-eslint/interface-name-prefix - export interface Endpoints { - '/v1/users.presence': { - GET: (params: { ids: string[] }) => UsersPresencePayload; - }; - } -} - const getPresence = ((): ((uid: UserPresence['_id']) => void) => { let timer: ReturnType; diff --git a/apps/meteor/client/providers/CallProvider/CallProvider.tsx b/apps/meteor/client/providers/CallProvider/CallProvider.tsx index fc6c9120b807..ba1e359e4acd 100644 --- a/apps/meteor/client/providers/CallProvider/CallProvider.tsx +++ b/apps/meteor/client/providers/CallProvider/CallProvider.tsx @@ -12,7 +12,7 @@ import { isVoipEventCallAbandoned, } from '@rocket.chat/core-typings'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useRoute, useUser, useSetting, useEndpoint, useStream } from '@rocket.chat/ui-contexts'; +import { useRoute, useUser, useSetting, useEndpoint, useStream, useSetModal } from '@rocket.chat/ui-contexts'; import { Random } from 'meteor/random'; import React, { useMemo, FC, useRef, useCallback, useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -21,8 +21,7 @@ import { OutgoingByeRequest } from 'sip.js/lib/core'; import { CustomSounds } from '../../../app/custom-sounds/client'; import { getUserPreference } from '../../../app/utils/client'; import { WrapUpCallModal } from '../../components/voip/modal/WrapUpCallModal'; -import { CallContext, CallContextValue } from '../../contexts/CallContext'; -import { imperativeModal } from '../../lib/imperativeModal'; +import { CallContext, CallContextValue, useCallCloseRoom } from '../../contexts/CallContext'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; import { QueueAggregator } from '../../lib/voip/QueueAggregator'; import { useVoipClient } from './hooks/useVoipClient'; @@ -45,6 +44,7 @@ export const CallProvider: FC = ({ children }) => { const voipEnabled = useSetting('VoIP_Enabled'); const subscribeToNotifyUser = useStream('notify-user'); const dispatchEvent = useEndpoint('POST', '/v1/voip/events'); + const setModal = useSetModal(); const result = useVoipClient(); const user = useUser(); @@ -56,8 +56,8 @@ export const CallProvider: FC = ({ children }) => { const [queueName, setQueueName] = useState(''); const openWrapUpModal = useCallback((): void => { - imperativeModal.open({ component: WrapUpCallModal }); - }, []); + setModal(() => ); + }, [setModal]); const [queueAggregator, setQueueAggregator] = useState(); diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index 8504a59b8b15..ef0e5885cf7c 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -32,13 +32,13 @@ const MeteorProvider: FC = ({ children }) => ( - - - + + + {children} - - - + + + diff --git a/apps/meteor/client/providers/RouterProvider.tsx b/apps/meteor/client/providers/RouterProvider.tsx index b6d4b83cf260..91f9711b4587 100644 --- a/apps/meteor/client/providers/RouterProvider.tsx +++ b/apps/meteor/client/providers/RouterProvider.tsx @@ -2,13 +2,11 @@ import { RouterContext, RouterContextValue } from '@rocket.chat/ui-contexts'; import { FlowRouter } from 'meteor/kadira:flow-router'; import { Tracker } from 'meteor/tracker'; import React, { FC } from 'react'; -import { Subscription, Unsubscribe } from 'use-subscription'; -const createSubscription = function (getValue: () => T): Subscription { +const createSubscription = function (getValue: () => T): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T] { let currentValue = Tracker.nonreactive(getValue); - return { - getCurrentValue: (): T => currentValue, - subscribe: (callback: () => void): Unsubscribe => { + return [ + (callback: () => void): (() => void) => { const computation = Tracker.autorun(() => { currentValue = getValue(); callback(); @@ -18,7 +16,8 @@ const createSubscription = function (getValue: () => T): Subscription { computation.stop(); }; }, - }; + (): T => currentValue, + ]; }; const queryRoutePath = ( diff --git a/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts b/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts index cf9e92695b2b..4dead8e2b29f 100644 --- a/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts +++ b/apps/meteor/client/providers/createReactiveSubscriptionFactory.ts @@ -1,16 +1,15 @@ import { Tracker } from 'meteor/tracker'; -import { Subscription, Unsubscribe } from 'use-subscription'; interface ISubscriptionFactory { - (...args: any[]): Subscription; + (...args: any[]): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T]; } export const createReactiveSubscriptionFactory = (computeCurrentValueWith: (...args: any[]) => T): ISubscriptionFactory => - (...args: any[]): Subscription => { + (...args: any[]): [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => T] => { const computeCurrentValue = (): T => computeCurrentValueWith(...args); - const callbacks = new Set(); + const callbacks = new Set<() => void>(); let currentValue = computeCurrentValue(); @@ -24,9 +23,8 @@ export const createReactiveSubscriptionFactory = }); }, 0); - return { - getCurrentValue: (): T => currentValue, - subscribe: (callback): Unsubscribe => { + return [ + (callback): (() => void) => { callbacks.add(callback); return (): void => { @@ -39,5 +37,6 @@ export const createReactiveSubscriptionFactory = } }; }, - }; + (): T => currentValue, + ]; }; diff --git a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx index 8a01dfce72ce..104a0b97496f 100644 --- a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx @@ -66,9 +66,9 @@ type RoomListRowProps = { /* @deprecated */ style?: AllHTMLAttributes['style']; - selected: boolean; + selected?: boolean; - sidebarViewMode: unknown; + sidebarViewMode?: unknown; }; function SideBarItemTemplateWithData({ diff --git a/apps/meteor/client/sidebar/RoomMenu.js b/apps/meteor/client/sidebar/RoomMenu.tsx similarity index 76% rename from apps/meteor/client/sidebar/RoomMenu.js rename to apps/meteor/client/sidebar/RoomMenu.tsx index 5e703f011e23..0457fb5096b7 100644 --- a/apps/meteor/client/sidebar/RoomMenu.js +++ b/apps/meteor/client/sidebar/RoomMenu.tsx @@ -1,3 +1,4 @@ +import { RoomType } from '@rocket.chat/core-typings'; import { Option, Menu } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { @@ -9,8 +10,10 @@ import { usePermission, useMethod, useTranslation, + TranslationKey, } from '@rocket.chat/ui-contexts'; -import React, { memo, useMemo } from 'react'; +import { Fields } from '@rocket.chat/ui-contexts/dist/UserContext'; +import React, { memo, ReactElement, useMemo } from 'react'; import { RoomManager } from '../../app/ui-utils/client/lib/RoomManager'; import { UiTextContext } from '../../definition/IRoomTypeConfig'; @@ -19,13 +22,24 @@ import { useDontAskAgain } from '../hooks/useDontAskAgain'; import { roomCoordinator } from '../lib/rooms/roomCoordinator'; import WarningModal from '../views/admin/apps/WarningModal'; -const fields = { - f: 1, - t: 1, - name: 1, +const fields: Fields = { + f: true, + t: true, + name: true, }; -const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = '' }) => { +type RoomMenuProps = { + rid: string; + unread?: boolean; + threadUnread?: boolean; + alert?: boolean; + roomOpen?: boolean; + type: RoomType; + cl?: boolean; + name?: string; +}; + +const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = '' }: RoomMenuProps): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const setModal = useSetModal(); @@ -36,7 +50,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = const subscription = useUserSubscription(rid, fields); const canFavorite = useSetting('Favorite_Rooms'); - const isFavorite = (subscription != null ? subscription.f : undefined) != null && subscription.f; + const isFavorite = Boolean(subscription?.f); const dontAskHideRoom = useDontAskAgain('hideRoom'); @@ -51,7 +65,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = const canLeaveChannel = usePermission('leave-c'); const canLeavePrivate = usePermission('leave-p'); - const canLeave = (() => { + const canLeave = ((): boolean => { if (type === 'c' && !canLeaveChannel) { return false; } @@ -62,7 +76,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = })(); const handleLeave = useMutableCallback(() => { - const leave = async () => { + const leave = async (): Promise => { try { await leaveRoom(rid); if (roomOpen) { @@ -70,7 +84,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = } RoomManager.close(rid); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } closeModal(); }; @@ -79,7 +93,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = setModal( { - const hide = async () => { + const hide = async (): Promise => { try { await hideRoom(rid); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } closeModal(); }; @@ -118,7 +132,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = label: t('Hide_room'), }} > - {t(warnText, name)} + {t(warnText as TranslationKey, name)} , ); }); @@ -137,7 +151,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = router.push({}); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } }); @@ -145,7 +159,7 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = try { await toggleFavorite(rid, !isFavorite); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: String(error) }); } }); @@ -159,15 +173,17 @@ const RoomMenu = ({ rid, unread, threadUnread, alert, roomOpen, type, cl, name = label: { label: isUnread ? t('Mark_read') : t('Mark_unread'), icon: 'flag' }, action: handleToggleRead, }, - ...(canFavorite && { - toggleFavorite: { - label: { - label: isFavorite ? t('Unfavorite') : t('Favorite'), - icon: isFavorite ? 'star-filled' : 'star', - }, - action: handleToggleFavorite, - }, - }), + ...(canFavorite + ? { + toggleFavorite: { + label: { + label: isFavorite ? t('Unfavorite') : t('Favorite'), + icon: isFavorite ? 'star-filled' : 'star', + }, + action: handleToggleFavorite, + }, + } + : {}), ...(canLeave && { leaveRoom: { label: { label: t('Leave_room'), icon: 'sign-out' }, diff --git a/apps/meteor/client/sidebar/Sidebar.stories.tsx b/apps/meteor/client/sidebar/Sidebar.stories.tsx index 8a9e70d9c20e..6db4e4243519 100644 --- a/apps/meteor/client/sidebar/Sidebar.stories.tsx +++ b/apps/meteor/client/sidebar/Sidebar.stories.tsx @@ -31,14 +31,8 @@ const settings: Record = { const settingContextValue: ContextType = { hasPrivateAccess: true, isLoading: false, - querySetting: (_id) => ({ - getCurrentValue: () => settings[_id], - subscribe: () => () => undefined, - }), - querySettings: () => ({ - getCurrentValue: () => [], - subscribe: () => () => undefined, - }), + querySetting: (_id) => [() => () => undefined, () => settings[_id]], + querySettings: () => [() => () => undefined, () => []], dispatch: async () => undefined, }; @@ -87,24 +81,15 @@ const userContextValue: ContextType = { roles: ['admin'], type: 'user', }, - queryPreference: (pref: string | ObjectId, defaultValue: T) => ({ - getCurrentValue: () => (typeof pref === 'string' ? (userPreferences[pref] as T) : defaultValue), - subscribe: () => () => undefined, - }), - querySubscriptions: () => ({ - getCurrentValue: () => subscriptions, - subscribe: () => () => undefined, - }), - querySubscription: () => ({ - getCurrentValue: () => undefined, - subscribe: () => () => undefined, - }), + queryPreference: (pref: string | ObjectId, defaultValue: T) => [ + () => () => undefined, + () => (typeof pref === 'string' ? (userPreferences[pref] as T) : defaultValue), + ], + querySubscriptions: () => [() => () => undefined, () => subscriptions], + querySubscription: () => [() => () => undefined, () => undefined], loginWithPassword: () => Promise.resolve(undefined), logout: () => Promise.resolve(undefined), - queryRoom: () => ({ - getCurrentValue: () => undefined, - subscribe: () => () => undefined, - }), + queryRoom: () => [() => () => undefined, () => undefined], }; export const Sidebar: Story = () => ( diff --git a/apps/meteor/client/sidebar/search/Row.js b/apps/meteor/client/sidebar/search/Row.tsx similarity index 73% rename from apps/meteor/client/sidebar/search/Row.js rename to apps/meteor/client/sidebar/search/Row.tsx index c1134cd5e45e..475eae9e2a47 100644 --- a/apps/meteor/client/sidebar/search/Row.js +++ b/apps/meteor/client/sidebar/search/Row.tsx @@ -1,9 +1,15 @@ -import React, { memo } from 'react'; +import { IRoom, ISubscription } from '@rocket.chat/core-typings'; +import React, { memo, ReactElement } from 'react'; import SideBarItemTemplateWithData from '../RoomList/SideBarItemTemplateWithData'; import UserItem from './UserItem'; -const Row = ({ item, data }) => { +type RowProps = { + item: ISubscription & IRoom; + data: Record; +}; + +const Row = ({ item, data }: RowProps): ReactElement => { const { t, SideBarItemTemplate, avatarTemplate: AvatarTemplate, useRealName, extended } = data; if (item.t === 'd' && !item.u) { @@ -21,7 +27,6 @@ const Row = ({ item, data }) => { return (
} - renderTrackHorizontal={(props) =>
} - /> - ); -}); - -export default ScrollerWithCustomProps; diff --git a/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.tsx b/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.tsx new file mode 100644 index 000000000000..3066c0d218e6 --- /dev/null +++ b/apps/meteor/client/sidebar/search/ScrollerWithCustomProps.tsx @@ -0,0 +1,16 @@ +import React, { forwardRef, ReactElement } from 'react'; + +import ScrollableContentWrapper from '../../components/ScrollableContentWrapper'; + +const ScrollerWithCustomProps = forwardRef(function ScrollerWithCustomProps(props, ref: React.Ref) { + return ( +
} + renderTrackHorizontal={(props): ReactElement =>
} + /> + ); +}); + +export default ScrollerWithCustomProps; diff --git a/apps/meteor/client/sidebar/search/SearchList.js b/apps/meteor/client/sidebar/search/SearchList.tsx similarity index 60% rename from apps/meteor/client/sidebar/search/SearchList.js rename to apps/meteor/client/sidebar/search/SearchList.tsx index 33f677ed8b39..80be10d7ac62 100644 --- a/apps/meteor/client/sidebar/search/SearchList.js +++ b/apps/meteor/client/sidebar/search/SearchList.tsx @@ -1,11 +1,31 @@ +import { RoomType } from '@rocket.chat/core-typings'; import { css } from '@rocket.chat/css-in-js'; import { Sidebar, TextInput, Box, Icon } from '@rocket.chat/fuselage'; -import { useMutableCallback, useDebouncedValue, useStableArray, useAutoFocus, useUniqueId } from '@rocket.chat/fuselage-hooks'; +import { + useMutableCallback, + useDebouncedValue, + useStableArray, + useAutoFocus, + useUniqueId, + useMergedRefs, +} from '@rocket.chat/fuselage-hooks'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { useUserPreference, useUserSubscriptions, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import React, { forwardRef, useState, useMemo, useEffect, useRef } from 'react'; -import { Virtuoso } from 'react-virtuoso'; +import React, { + forwardRef, + useState, + useMemo, + useEffect, + useRef, + ReactElement, + MutableRefObject, + SetStateAction, + Dispatch, + FormEventHandler, + Ref, +} from 'react'; +import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import tinykeys from 'tinykeys'; import { AsyncStatePhase } from '../../hooks/useAsyncState'; @@ -15,8 +35,8 @@ import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode'; import Row from './Row'; import ScrollerWithCustomProps from './ScrollerWithCustomProps'; -const shortcut = (() => { - if (!Meteor.Device.isDesktop()) { +const shortcut = ((): string => { + if (!(Meteor as any).Device.isDesktop()) { return ''; } if (window.navigator.platform.toLowerCase().includes('mac')) { @@ -25,9 +45,9 @@ const shortcut = (() => { return '(\u2303+K)'; })(); -const useSpotlight = (filterText = '', usernames) => { +const useSpotlight = (filterText: string, usernames: string[]) => { const expression = /(@|#)?(.*)/i; - const [, mention, name] = filterText.match(expression); + const [, mention, name] = filterText.match(expression) || []; const searchForChannels = mention === '#'; const searchForDMs = mention === '@'; @@ -41,9 +61,10 @@ const useSpotlight = (filterText = '', usernames) => { } return { users: true, rooms: true }; }, [searchForChannels, searchForDMs]); + const args = useMemo(() => [name, usernames, type], [type, name, usernames]); - const { value: data = { users: [], rooms: [] }, phase: status } = useMethodData('spotlight', args); + const { value: data, phase: status } = useMethodData('spotlight', args); return useMemo(() => { if (!data) { @@ -60,11 +81,10 @@ const options = { }, }; -const useSearchItems = (filterText) => { +const useSearchItems = (filterText: string): any => { const expression = /(@|#)?(.*)/i; - const teste = filterText.match(expression); + const [, type, name] = filterText.match(expression) || []; - const [, type, name] = teste; const query = useMemo(() => { const filterRegex = new RegExp(escapeRegExp(name), 'i'); @@ -76,23 +96,36 @@ const useSearchItems = (filterText) => { }; }, [name, type]); - const localRooms = useUserSubscriptions(query, options); + const localRooms: { rid: string; t: RoomType; _id: string; name: string; uids?: string }[] = useUserSubscriptions(query, options); - const usernamesFromClient = useStableArray([...localRooms?.map(({ t, name }) => (t === 'd' ? name : null))].filter(Boolean)); + const usernamesFromClient = useStableArray([...localRooms?.map(({ t, name }) => (t === 'd' ? name : null))].filter(Boolean)) as string[]; const { data: spotlight, status } = useSpotlight(filterText, usernamesFromClient); return useMemo(() => { - const resultsFromServer = []; + const filterUsersUnique = ({ _id }: { _id: string }, index: number, arr: { _id: string }[]): boolean => + index === arr.findIndex((user) => _id === user._id); - const filterUsersUnique = ({ _id }, index, arr) => index === arr.findIndex((user) => _id === user._id); - const roomFilter = (room) => + const roomFilter = (room: { t: string; uids?: string[]; _id: string; name?: string }): boolean => !localRooms.find( - (item) => (room.t === 'd' && room.uids?.length > 1 && room.uids.includes(item._id)) || [item.rid, item._id].includes(room._id), + (item) => + (room.t === 'd' && room.uids && room.uids.length > 1 && room.uids?.includes(item._id)) || [item.rid, item._id].includes(room._id), ); - const usersfilter = (user) => !localRooms.find((room) => room.t === 'd' && room.uids?.length === 2 && room.uids.includes(user._id)); - - const userMap = (user) => ({ + const usersfilter = (user: { _id: string }): boolean => + !localRooms.find((room) => room.t === 'd' && room.uids && room.uids?.length === 2 && room.uids.includes(user._id)); + + const userMap = (user: { + _id: string; + name: string; + username: string; + avatarETag?: string; + }): { + _id: string; + t: string; + name: string; + fname: string; + avatarETag?: string; + } => ({ _id: user._id, t: 'd', name: user.username, @@ -100,17 +133,27 @@ const useSearchItems = (filterText) => { avatarETag: user.avatarETag, }); - const exact = resultsFromServer.filter((item) => [item.usernamame, item.name, item.fname].includes(name)); + type resultsFromServerType = { + _id: string; + t: string; + name: string; + fname?: string; + avatarETag?: string | undefined; + uids?: string[] | undefined; + }[]; + const resultsFromServer: resultsFromServerType = []; resultsFromServer.push(...spotlight.users.filter(filterUsersUnique).filter(usersfilter).map(userMap)); resultsFromServer.push(...spotlight.rooms.filter(roomFilter)); + const exact = resultsFromServer?.filter((item) => [item.name, item.fname].includes(name)); + return { data: Array.from(new Set([...exact, ...localRooms, ...resultsFromServer])), status }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [localRooms, name, spotlight]); }; -const useInput = (initial) => { +const useInput = (initial: string): { value: string; onChange: FormEventHandler; setValue: Dispatch> } => { const [value, setValue] = useState(initial); const onChange = useMutableCallback((e) => { setValue(e.currentTarget.value); @@ -118,12 +161,12 @@ const useInput = (initial) => { return { value, onChange, setValue }; }; -const toggleSelectionState = (next, current, input) => { - input.setAttribute('aria-activedescendant', next.id); - next.setAttribute('aria-selected', true); +const toggleSelectionState = (next: HTMLElement, current: HTMLElement | undefined, input: HTMLElement | undefined): void => { + input?.setAttribute('aria-activedescendant', next.id); + next.setAttribute('aria-selected', 'true'); next.classList.add('rcx-sidebar-item--selected'); if (current) { - current.setAttribute('aria-selected', false); + current.removeAttribute('aria-selected'); current.classList.remove('rcx-sidebar-item--selected'); } }; @@ -131,17 +174,23 @@ const toggleSelectionState = (next, current, input) => { /** * @type import('react').ForwardRefExoticComponent<{ onClose: unknown } & import('react').RefAttributes> */ -const SearchList = forwardRef(function SearchList({ onClose }, ref) { + +type SearchListProps = { + onClose: () => void; +}; + +const SearchList = forwardRef(function SearchList({ onClose }: SearchListProps, ref): ReactElement { const listId = useUniqueId(); const t = useTranslation(); const { setValue: setFilterValue, ...filter } = useInput(''); - const autofocus = useAutoFocus(); + const cursorRef = useRef(null); + const autofocus: Ref = useMergedRefs(useAutoFocus(), cursorRef); - const listRef = useRef(); - const boxRef = useRef(); + const listRef = useRef(null); + const boxRef = useRef(null); - const selectedElement = useRef(); + const selectedElement: MutableRefObject = useRef(null); const itemIndexRef = useRef(0); const sidebarViewMode = useUserPreference('sidebarViewMode'); @@ -175,13 +224,13 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { let nextSelectedElement = null; if (dir === 'up') { - nextSelectedElement = selectedElement.current.parentElement.previousSibling.querySelector('a'); + nextSelectedElement = (selectedElement.current?.parentElement?.previousSibling as HTMLElement).querySelector('a'); } else { - nextSelectedElement = selectedElement.current.parentElement.nextSibling.querySelector('a'); + nextSelectedElement = (selectedElement.current?.parentElement?.nextSibling as HTMLElement).querySelector('a'); } if (nextSelectedElement) { - toggleSelectionState(nextSelectedElement, selectedElement.current, autofocus.current); + toggleSelectionState(nextSelectedElement, selectedElement.current || undefined, cursorRef?.current || undefined); return nextSelectedElement; } return selectedElement.current; @@ -189,12 +238,12 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { const resetCursor = useMutableCallback(() => { itemIndexRef.current = 0; - listRef.current.scrollToIndex({ index: itemIndexRef.current }); + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); selectedElement.current = boxRef.current?.querySelector('a.rcx-sidebar-item'); if (selectedElement.current) { - toggleSelectionState(selectedElement.current, undefined, autofocus.current); + toggleSelectionState(selectedElement.current, undefined, cursorRef?.current || undefined); } }); @@ -207,10 +256,10 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { }, [filterText, resetCursor]); useEffect(() => { - if (!autofocus.current) { + if (!cursorRef?.current) { return; } - const unsubscribe = tinykeys(autofocus.current, { + const unsubscribe = tinykeys(cursorRef?.current, { Escape: (event) => { event.preventDefault(); setFilterValue((value) => { @@ -225,13 +274,13 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { ArrowUp: () => { const currentElement = changeSelection('up'); itemIndexRef.current = Math.max(itemIndexRef.current - 1, 0); - listRef.current.scrollToIndex({ index: itemIndexRef.current }); + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); selectedElement.current = currentElement; }, ArrowDown: () => { const currentElement = changeSelection('down'); itemIndexRef.current = Math.min(itemIndexRef.current + 1, items?.length + 1); - listRef.current.scrollToIndex({ index: itemIndexRef.current }); + listRef.current?.scrollToIndex({ index: itemIndexRef.current }); selectedElement.current = currentElement; }, Enter: () => { @@ -240,10 +289,10 @@ const SearchList = forwardRef(function SearchList({ onClose }, ref) { } }, }); - return () => { + return (): void => { unsubscribe(); }; - }, [autofocus, changeSelection, items.length, onClose, resetCursor, setFilterValue]); + }, [cursorRef, changeSelection, items.length, onClose, resetCursor, setFilterValue]); return ( - + } + itemContent={(_, data): ReactElement => } ref={listRef} /> diff --git a/apps/meteor/client/sidebar/search/UserItem.js b/apps/meteor/client/sidebar/search/UserItem.tsx similarity index 59% rename from apps/meteor/client/sidebar/search/UserItem.js rename to apps/meteor/client/sidebar/search/UserItem.tsx index febaa41717c6..21840bcc414a 100644 --- a/apps/meteor/client/sidebar/search/UserItem.js +++ b/apps/meteor/client/sidebar/search/UserItem.tsx @@ -1,13 +1,28 @@ +import { IUser } from '@rocket.chat/core-typings'; import { Sidebar } from '@rocket.chat/fuselage'; -import React, { memo } from 'react'; +import React, { memo, ReactElement } from 'react'; import { ReactiveUserStatus } from '../../components/UserStatus'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; -const UserItem = ({ item, id, style, t, SideBarItemTemplate, AvatarTemplate, useRealName }) => { +type UserItemProps = { + item: { + name?: string; + fname?: string; + _id: IUser['_id']; + t: string; + }; + t: (value: string) => string; + SideBarItemTemplate: any; + AvatarTemplate: any; + id: string; + style?: CSSStyleRule; + useRealName?: boolean; +}; +const UserItem = ({ item, id, style, t, SideBarItemTemplate, AvatarTemplate, useRealName }: UserItemProps): ReactElement => { const title = useRealName ? item.fname || item.name : item.name || item.fname; const icon = ( - + ); @@ -16,14 +31,13 @@ const UserItem = ({ item, id, style, t, SideBarItemTemplate, AvatarTemplate, use return ( } icon={icon} - style={style} /> ); }; diff --git a/apps/meteor/client/startup/unread.ts b/apps/meteor/client/startup/unread.ts index 15c40de2d9c4..6c076e4ba107 100644 --- a/apps/meteor/client/startup/unread.ts +++ b/apps/meteor/client/startup/unread.ts @@ -1,9 +1,9 @@ import type { ISubscription } from '@rocket.chat/core-typings'; +import { manageFavicon } from '@rocket.chat/favicon'; import { Meteor } from 'meteor/meteor'; import { Session } from 'meteor/session'; import { Tracker } from 'meteor/tracker'; -import { Favico } from '../../app/favico/client'; import { ChatSubscription, ChatRoom } from '../../app/models/client'; import { settings } from '../../app/settings/client'; import { getUserPreference } from '../../app/utils/client'; @@ -75,12 +75,7 @@ Meteor.startup(() => { }); Meteor.startup(() => { - const favicon = new (Favico as any)({ - position: 'up', - animation: 'none', - }); - - window.favico = favicon; + const updateFavicon = manageFavicon(); Tracker.autorun(() => { const siteName = settings.get('Site_Name') ?? ''; @@ -88,11 +83,7 @@ Meteor.startup(() => { const unread = Session.get('unread'); fireGlobalEvent('unread-changed', unread); - if (favicon) { - favicon.badge(unread, { - bgColor: typeof unread !== 'number' ? '#3d8a3a' : '#ac1b1b', - }); - } + updateFavicon(unread); document.title = unread === '' ? siteName : `(${unread}) ${siteName}`; }); diff --git a/apps/meteor/client/views/account/AccountSidebar.tsx b/apps/meteor/client/views/account/AccountSidebar.tsx index aff5500e00f8..37662e5e8b82 100644 --- a/apps/meteor/client/views/account/AccountSidebar.tsx +++ b/apps/meteor/client/views/account/AccountSidebar.tsx @@ -1,17 +1,17 @@ import { useRoutePath, useCurrentRoute, useTranslation } from '@rocket.chat/ui-contexts'; import React, { memo, ReactElement, useCallback, useEffect } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { itemsSubscription } from '.'; import { menu, SideNav } from '../../../app/ui-utils/client'; import Sidebar from '../../components/Sidebar'; import { isLayoutEmbedded } from '../../lib/utils/isLayoutEmbedded'; import SettingsProvider from '../../providers/SettingsProvider'; +import { getAccountSidebarItems, subscribeToAccountSidebarItems } from './sidebarItems'; const AccountSidebar = (): ReactElement => { const t = useTranslation(); - const items = useSubscription(itemsSubscription); + const items = useSyncExternalStore(subscribeToAccountSidebarItems, getAccountSidebarItems); const closeFlex = useCallback(() => { if (isLayoutEmbedded()) { diff --git a/apps/meteor/client/views/account/index.ts b/apps/meteor/client/views/account/index.ts index 01c25a32a809..8fc7e4da2521 100644 --- a/apps/meteor/client/views/account/index.ts +++ b/apps/meteor/client/views/account/index.ts @@ -1,2 +1,2 @@ export { registerAccountRoute } from './routes'; -export { registerAccountSidebarItem, unregisterSidebarItem, itemsSubscription } from './sidebarItems'; +export { registerAccountSidebarItem, unregisterSidebarItem } from './sidebarItems'; diff --git a/apps/meteor/client/views/account/sidebarItems.ts b/apps/meteor/client/views/account/sidebarItems.ts index 8ce98bc85e86..77ed0fa01267 100644 --- a/apps/meteor/client/views/account/sidebarItems.ts +++ b/apps/meteor/client/views/account/sidebarItems.ts @@ -5,7 +5,8 @@ import { createSidebarItems } from '../../lib/createSidebarItems'; export const { registerSidebarItem: registerAccountSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems: getAccountSidebarItems, + subscribeToSidebarItems: subscribeToAccountSidebarItems, } = createSidebarItems([ { href: 'preferences', diff --git a/apps/meteor/client/views/admin/EditableSettingsContext.ts b/apps/meteor/client/views/admin/EditableSettingsContext.ts index 2c74b679fc9e..c8898f09a93d 100644 --- a/apps/meteor/client/views/admin/EditableSettingsContext.ts +++ b/apps/meteor/client/views/admin/EditableSettingsContext.ts @@ -1,7 +1,7 @@ import { ISettingBase, SectionName, SettingId, GroupId, TabId, ISettingColor } from '@rocket.chat/core-typings'; import { SettingsContextQuery } from '@rocket.chat/ui-contexts'; import { createContext, useContext, useMemo } from 'react'; -import { useSubscription, Subscription, Unsubscribe } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; export type EditableSetting = (ISettingBase | ISettingColor) & { disabled: boolean; @@ -14,58 +14,53 @@ export type EditableSettingsContextQuery = SettingsContextQuery & { }; export type EditableSettingsContextValue = { - readonly queryEditableSetting: (_id: SettingId) => Subscription; - readonly queryEditableSettings: (query: EditableSettingsContextQuery) => Subscription; - readonly queryGroupSections: (_id: GroupId, tab?: TabId) => Subscription; - readonly queryGroupTabs: (_id: GroupId) => Subscription; + readonly queryEditableSetting: ( + _id: SettingId, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => EditableSetting | undefined]; + readonly queryEditableSettings: ( + query: EditableSettingsContextQuery, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => EditableSetting[]]; + readonly queryGroupSections: ( + _id: GroupId, + tab?: TabId, + ) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => SectionName[]]; + readonly queryGroupTabs: (_id: GroupId) => [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => TabId[]]; readonly dispatch: (changes: Partial[]) => void; }; export const EditableSettingsContext = createContext({ - queryEditableSetting: () => ({ - getCurrentValue: (): undefined => undefined, - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryEditableSettings: () => ({ - getCurrentValue: (): EditableSetting[] => [], - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryGroupSections: () => ({ - getCurrentValue: (): SectionName[] => [], - subscribe: (): Unsubscribe => (): void => undefined, - }), - queryGroupTabs: () => ({ - getCurrentValue: (): TabId[] => [], - subscribe: (): Unsubscribe => (): void => undefined, - }), + queryEditableSetting: () => [(): (() => void) => (): void => undefined, (): undefined => undefined], + queryEditableSettings: () => [(): (() => void) => (): void => undefined, (): EditableSetting[] => []], + queryGroupSections: () => [(): (() => void) => (): void => undefined, (): SectionName[] => []], + queryGroupTabs: () => [(): (() => void) => (): void => undefined, (): TabId[] => []], dispatch: () => undefined, }); export const useEditableSetting = (_id: SettingId): EditableSetting | undefined => { const { queryEditableSetting } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryEditableSetting(_id), [queryEditableSetting, _id]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryEditableSetting(_id), [queryEditableSetting, _id]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettings = (query?: EditableSettingsContextQuery): EditableSetting[] => { const { queryEditableSettings } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryEditableSettings(query ?? {}), [queryEditableSettings, query]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryEditableSettings(query ?? {}), [queryEditableSettings, query]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettingsGroupSections = (_id: SettingId, tab?: TabId): SectionName[] => { const { queryGroupSections } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryGroupSections(_id, tab), [queryGroupSections, _id, tab]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryGroupSections(_id, tab), [queryGroupSections, _id, tab]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettingsGroupTabs = (_id: SettingId): TabId[] => { const { queryGroupTabs } = useContext(EditableSettingsContext); - const subscription = useMemo(() => queryGroupTabs(_id), [queryGroupTabs, _id]); - return useSubscription(subscription); + const [subscribe, getSnapshot] = useMemo(() => queryGroupTabs(_id), [queryGroupTabs, _id]); + return useSyncExternalStore(subscribe, getSnapshot); }; export const useEditableSettingsDispatch = (): ((changes: Partial[]) => void) => diff --git a/apps/meteor/client/views/admin/apps/AppMenu.js b/apps/meteor/client/views/admin/apps/AppMenu.js index 7daf4505c9b5..ed4bbf9b0e76 100644 --- a/apps/meteor/client/views/admin/apps/AppMenu.js +++ b/apps/meteor/client/views/admin/apps/AppMenu.js @@ -1,5 +1,5 @@ import { Box, Icon, Menu } from '@rocket.chat/fuselage'; -import { useSetModal, useMethod, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; +import { useSetModal, useMethod, useEndpoint, useTranslation, useRoute, useRouteParameter } from '@rocket.chat/ui-contexts'; import React, { useMemo, useCallback } from 'react'; import CloudLoginModal from './CloudLoginModal'; @@ -11,6 +11,8 @@ function AppMenu({ app, ...props }) { const t = useTranslation(); const setModal = useSetModal(); const checkUserLoggedIn = useMethod('cloud:checkUserLoggedIn'); + const appsRoute = useRoute('admin-apps'); + const context = useRouteParameter('context'); const setAppStatus = useEndpoint('POST', `/apps/${app.id}/status`); const buildExternalUrl = useEndpoint('GET', '/apps'); @@ -64,6 +66,10 @@ function AppMenu({ app, ...props }) { setModal(); }, [checkUserLoggedIn, setModal, closeModal, buildExternalUrl, app.id, app.purchaseType, syncApp]); + const handleViewLogs = useCallback(() => { + appsRoute.push({ context: 'details', id: app.id, version: app.version, tab: 'logs' }); + }, [app.id, app.version, appsRoute]); + const handleDisable = useCallback(() => { const confirm = async () => { closeModal(); @@ -124,6 +130,17 @@ function AppMenu({ app, ...props }) { action: handleSubscription, }, }), + ...(context !== 'details' && { + viewLogs: { + label: ( + + + {t('View_Logs')} + + ), + action: handleViewLogs, + }, + }), ...(app.installed && isAppEnabled && { disable: { @@ -160,7 +177,18 @@ function AppMenu({ app, ...props }) { }, }), }), - [canAppBeSubscribed, t, handleSubscription, app.installed, isAppEnabled, handleDisable, handleEnable, handleUninstall], + [ + canAppBeSubscribed, + t, + handleSubscription, + context, + handleViewLogs, + app.installed, + isAppEnabled, + handleDisable, + handleEnable, + handleUninstall, + ], ); return ; diff --git a/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx b/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx index e188e396bd7d..664c18838c63 100644 --- a/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx +++ b/apps/meteor/client/views/admin/sidebar/AdminSidebarPages.tsx @@ -1,10 +1,10 @@ import { Box } from '@rocket.chat/fuselage'; import React, { memo, FC } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import SidebarItemsAssembler from '../../../components/Sidebar/SidebarItemsAssembler'; import { useUpgradeTabParams } from '../../hooks/useUpgradeTabParams'; -import { itemsSubscription } from '../sidebarItems'; +import { subscribeToAdminSidebarItems, getAdminSidebarItems } from '../sidebarItems'; import UpgradeTab from './UpgradeTab'; type AdminSidebarPagesProps = { @@ -12,7 +12,8 @@ type AdminSidebarPagesProps = { }; const AdminSidebarPages: FC = ({ currentPath }) => { - const items = useSubscription(itemsSubscription); + const items = useSyncExternalStore(subscribeToAdminSidebarItems, getAdminSidebarItems); + const { tabType, trialEndDate, isLoading } = useUpgradeTabParams(); return ( diff --git a/apps/meteor/client/views/admin/sidebarItems.ts b/apps/meteor/client/views/admin/sidebarItems.ts index a9dc4bf53514..d3f195069647 100644 --- a/apps/meteor/client/views/admin/sidebarItems.ts +++ b/apps/meteor/client/views/admin/sidebarItems.ts @@ -4,7 +4,8 @@ import { createSidebarItems } from '../../lib/createSidebarItems'; export const { registerSidebarItem: registerAdminSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems: getAdminSidebarItems, + subscribeToSidebarItems: subscribeToAdminSidebarItems, } = createSidebarItems([ { href: 'admin-info', diff --git a/apps/meteor/client/views/admin/users/EditUser.js b/apps/meteor/client/views/admin/users/EditUser.js index a8130ca64484..9c5ccbf6807a 100644 --- a/apps/meteor/client/views/admin/users/EditUser.js +++ b/apps/meteor/client/views/admin/users/EditUser.js @@ -14,7 +14,6 @@ const getInitialValue = (data) => ({ name: data.name ?? '', password: '', username: data.username, - status: data.status, bio: data.bio ?? '', nickname: data.nickname ?? '', email: (data.emails && data.emails.length && data.emails[0].address) || '', diff --git a/apps/meteor/client/views/banners/BannerRegion.tsx b/apps/meteor/client/views/banners/BannerRegion.tsx index c87667e5f3de..a840540482b4 100644 --- a/apps/meteor/client/views/banners/BannerRegion.tsx +++ b/apps/meteor/client/views/banners/BannerRegion.tsx @@ -1,12 +1,12 @@ import React, { FC } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import * as banners from '../../lib/banners'; import LegacyBanner from './LegacyBanner'; import UiKitBanner from './UiKitBanner'; const BannerRegion: FC = () => { - const payload = useSubscription(banners.firstSubscription); + const payload = useSyncExternalStore(...banners.firstSubscription); if (!payload) { return null; diff --git a/apps/meteor/client/views/hooks/useActionSpread.ts b/apps/meteor/client/views/hooks/useActionSpread.ts index 21a089ba1443..b1c2fddc9e9b 100644 --- a/apps/meteor/client/views/hooks/useActionSpread.ts +++ b/apps/meteor/client/views/hooks/useActionSpread.ts @@ -1,14 +1,14 @@ -import { useMemo } from 'react'; +import { useMemo, ReactNode } from 'react'; -type Action = { - label: string; - icon: string; - action: () => any; +export type Action = { + label: ReactNode; + icon?: string; + action: () => void; }; type MenuOption = { - label: { label: string; icon: string }; - action: Function; + label: { label: ReactNode; icon?: string }; + action: () => void; }; const mapOptions = ([key, { action, label, icon }]: [string, Action]): [string, MenuOption] => [ diff --git a/apps/meteor/client/views/omnichannel/additionalForms.tsx b/apps/meteor/client/views/omnichannel/additionalForms.tsx index a8fab3c41a2c..800458471eba 100644 --- a/apps/meteor/client/views/omnichannel/additionalForms.tsx +++ b/apps/meteor/client/views/omnichannel/additionalForms.tsx @@ -1,28 +1,29 @@ -/* eslint-disable @typescript-eslint/no-empty-interface */ import { ReactElement } from 'react'; -import { Unsubscribe, useSubscription, Subscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; -// eslint-disable-next-line @typescript-eslint/interface-name-prefix +/* eslint-disable @typescript-eslint/no-empty-interface */ +/* eslint-disable @typescript-eslint/interface-name-prefix */ export interface EEFormHooks {} const createFormSubscription = (): { registerForm: (form: EEFormHooks) => void; unregisterForm: (form: keyof EEFormHooks) => void; - formsSubscription: Subscription; + formsSubscription: readonly [subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => EEFormHooks]; getForm: (form: keyof EEFormHooks) => () => ReactElement; } => { let forms = {} as EEFormHooks; let updateCb = (): void => undefined; - const formsSubscription: Subscription = { - subscribe: (cb: () => void): Unsubscribe => { + const formsSubscription = [ + (cb: () => void): (() => void) => { updateCb = cb; return (): void => { updateCb = (): void => undefined; }; }, - getCurrentValue: (): EEFormHooks => forms, - }; + (): EEFormHooks => forms, + ] as const; + const registerForm = (newForm: EEFormHooks): void => { forms = { ...forms, ...newForm }; updateCb(); @@ -37,6 +38,8 @@ const createFormSubscription = (): { return { registerForm, unregisterForm, formsSubscription, getForm }; }; -export const { registerForm, unregisterForm, formsSubscription, getForm } = createFormSubscription(); +const { registerForm, unregisterForm, formsSubscription, getForm } = createFormSubscription(); + +export { registerForm, unregisterForm, getForm }; -export const useFormsSubscription = (): EEFormHooks => useSubscription(formsSubscription); +export const useFormsSubscription = (): EEFormHooks => useSyncExternalStore(...formsSubscription); diff --git a/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx b/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx index b9ed52e54b9b..cb3721baec75 100644 --- a/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx +++ b/apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx @@ -3,13 +3,12 @@ import { Field, TextInput, Button, Margins, Box, MultiSelect, Icon, Select } fro import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useSetting, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo, useRef, useState, FC, ReactElement } from 'react'; -import { useSubscription } from 'use-subscription'; import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress'; import VerticalBar from '../../../components/VerticalBar'; import { useForm } from '../../../hooks/useForm'; import UserInfo from '../../room/contextualBar/UserInfo'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; // TODO: TYPE: // Department @@ -46,7 +45,7 @@ const AgentEdit: FC = ({ data, userDepartments, availableDepartm () => (userDepartments.departments ? userDepartments.departments.map(({ departmentId }) => departmentId) : []), [userDepartments], ); - const eeForms = useSubscription(formsSubscription); + const eeForms = useFormsSubscription(); const saveRef = useRef({ values: {}, diff --git a/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js b/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js index 59c2cc3fadd3..524fe89fdb5c 100644 --- a/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js +++ b/apps/meteor/client/views/omnichannel/analytics/InterchangeableChart.js @@ -15,11 +15,13 @@ const getChartTooltips = (chartName) => { case 'Avg_reaction_time': return { callbacks: { - title(tooltipItem, data) { - return data.labels[tooltipItem[0].index]; + title([ctx]) { + const { dataset } = ctx; + return dataset.label; }, - label(tooltipItem, data) { - return secondsToHHMMSS(data.datasets[0].data[tooltipItem.index]); + label(ctx) { + const { dataset, dataIndex } = ctx; + return secondsToHHMMSS(dataset.data[dataIndex]); }, }, }; @@ -53,7 +55,7 @@ const InterchangeableChart = ({ departmentId, dateRange, chartName, ...props }) tooltipCallbacks, }); } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); + dispatchToastMessage({ type: 'error', message: error.message }); } }); diff --git a/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js index 7eae87457fbc..c274ebee272d 100644 --- a/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js +++ b/apps/meteor/client/views/omnichannel/businessHours/BusinessHoursFormContainer.js @@ -1,12 +1,11 @@ import { FieldGroup, Box } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import React, { useEffect, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import { businessHourManager } from '../../../../app/livechat/client/views/app/business-hours/BusinessHours'; import { useForm } from '../../../hooks/useForm'; import { useReactiveValue } from '../../../hooks/useReactiveValue'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import BusinessHourForm from './BusinessHoursForm'; const useChangeHandler = (name, ref) => @@ -25,7 +24,7 @@ const getInitalData = ({ workHours }) => ({ const cleanFunc = () => {}; const BusinessHoursFormContainer = ({ data, saveRef, onChange = () => {} }) => { - const forms = useSubscription(formsSubscription); + const forms = useFormsSubscription(); const [hasChangesMultiple, setHasChangesMultiple] = useState(false); const [hasChangesTimeZone, setHasChangesTimeZone] = useState(false); diff --git a/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx b/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx index d7b92cb3463d..d5432f10eb23 100644 --- a/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx +++ b/apps/meteor/client/views/omnichannel/currentChats/FilterByText.tsx @@ -3,13 +3,12 @@ import { useMutableCallback, useLocalStorage } from '@rocket.chat/fuselage-hooks import { useSetModal, useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import moment from 'moment'; import React, { Dispatch, FC, SetStateAction, useEffect, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; import AutoCompleteAgent from '../../../components/AutoCompleteAgent'; import AutoCompleteDepartment from '../../../components/AutoCompleteDepartment'; import GenericModal from '../../../components/GenericModal'; import { useEndpointData } from '../../../hooks/useEndpointData'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import Label from './Label'; import RemoveAllClosed from './RemoveAllClosed'; @@ -64,7 +63,7 @@ const FilterByText: FilterByTextType = ({ setFilter, reload, ...props }) => { setCustomFields([]); }); - const forms = useSubscription(formsSubscription); + const forms = useFormsSubscription() as any; // TODO: Refactor the formsSubscription to use components instead of hooks (since the only thing the hook does is return a component) // Conditional hook was required since the whole formSubscription uses hooks in an incorrect manner diff --git a/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js b/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js index 852d31215bf0..89fdf1cff012 100644 --- a/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js +++ b/apps/meteor/client/views/omnichannel/customFields/EditCustomFieldsPage.js @@ -2,11 +2,10 @@ import { Box, Button, Icon, ButtonGroup, FieldGroup } from '@rocket.chat/fuselag import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import Page from '../../../components/Page'; import { useForm } from '../../../hooks/useForm'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import CustomFieldsForm from './CustomFieldsForm'; const getInitialValues = (cf) => ({ @@ -24,7 +23,7 @@ const EditCustomFieldsPage = ({ customField, id, reload }) => { const [additionalValues, setAdditionalValues] = useState({}); - const { useCustomFieldsAdditionalForm = () => {} } = useSubscription(formsSubscription); + const { useCustomFieldsAdditionalForm = () => {} } = useFormsSubscription(); const AdditionalForm = useCustomFieldsAdditionalForm(); const router = useRoute('omnichannel-customfields'); diff --git a/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js b/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js index 65b02030cfec..b4d4dcd4a447 100644 --- a/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js +++ b/apps/meteor/client/views/omnichannel/customFields/NewCustomFieldsPage.js @@ -2,11 +2,10 @@ import { Box, Button, Icon, FieldGroup, ButtonGroup } from '@rocket.chat/fuselag import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback, useState } from 'react'; -import { useSubscription } from 'use-subscription'; import Page from '../../../components/Page'; import { useForm } from '../../../hooks/useForm'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import CustomFieldsForm from './CustomFieldsForm'; const initialValues = { @@ -23,7 +22,7 @@ const NewCustomFieldsPage = ({ reload }) => { const [additionalValues, setAdditionalValues] = useState({}); - const { useCustomFieldsAdditionalForm = () => {} } = useSubscription(formsSubscription); + const { useCustomFieldsAdditionalForm = () => {} } = useFormsSubscription(); const AdditionalForm = useCustomFieldsAdditionalForm(); const router = useRoute('omnichannel-customfields'); diff --git a/apps/meteor/client/views/omnichannel/departments/EditDepartment.js b/apps/meteor/client/views/omnichannel/departments/EditDepartment.js index ccde330932d0..25a62e664262 100644 --- a/apps/meteor/client/views/omnichannel/departments/EditDepartment.js +++ b/apps/meteor/client/views/omnichannel/departments/EditDepartment.js @@ -15,7 +15,6 @@ import { import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useRoute, useMethod, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useMemo, useState, useRef } from 'react'; -import { useSubscription } from 'use-subscription'; import { validateEmail } from '../../../../lib/emailValidator'; import Page from '../../../components/Page'; @@ -24,7 +23,7 @@ import { useRecordList } from '../../../hooks/lists/useRecordList'; import { useComponentDidUpdate } from '../../../hooks/useComponentDidUpdate'; import { useForm } from '../../../hooks/useForm'; import { AsyncStatePhase } from '../../../lib/asyncState'; -import { formsSubscription } from '../additionalForms'; +import { useFormsSubscription } from '../additionalForms'; import DepartmentsAgentsTable from './DepartmentsAgentsTable'; function withDefault(key, defaultValue) { @@ -42,7 +41,7 @@ function EditDepartment({ data, id, title, reload, allowedToForwardData }) { useDepartmentForwarding = () => {}, useDepartmentBusinessHours = () => {}, useSelectForwardDepartment = () => {}, - } = useSubscription(formsSubscription); + } = useFormsSubscription(); const initialAgents = useRef((data && data.agents) || []); diff --git a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js index 0f4a5578c4b3..fdcdeeaabc6a 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js +++ b/apps/meteor/client/views/omnichannel/directory/chats/contextualBar/RoomEdit.js @@ -2,7 +2,6 @@ import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useState, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; import { hasAtLeastOnePermission } from '../../../../../../app/authorization/client'; import CustomFieldsForm from '../../../../../components/CustomFieldsForm'; @@ -11,7 +10,7 @@ import VerticalBar from '../../../../../components/VerticalBar'; import { AsyncStatePhase } from '../../../../../hooks/useAsyncState'; import { useEndpointData } from '../../../../../hooks/useEndpointData'; import { useForm } from '../../../../../hooks/useForm'; -import { formsSubscription } from '../../../additionalForms'; +import { useFormsSubscription } from '../../../additionalForms'; import { FormSkeleton } from '../../Skeleton'; const initialValuesRoom = { @@ -45,7 +44,7 @@ function RoomEdit({ room, visitor, reload, reloadInfo, close }) { const { handleTopic, handleTags, handlePriorityId } = handlersRoom; const { topic, tags, priorityId } = valuesRoom; - const forms = useSubscription(formsSubscription); + const forms = useFormsSubscription(); const { usePrioritiesSelect = () => {} } = forms; diff --git a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js index 6380f16ab565..fda1dc2e8dcc 100644 --- a/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js +++ b/apps/meteor/client/views/omnichannel/directory/contacts/contextualBar/ContactNewEdit.js @@ -2,7 +2,6 @@ import { Field, TextInput, ButtonGroup, Button } from '@rocket.chat/fuselage'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useState, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; import { hasAtLeastOnePermission } from '../../../../../../app/authorization/client'; import { validateEmail } from '../../../../../../lib/emailValidator'; @@ -13,7 +12,7 @@ import { useComponentDidUpdate } from '../../../../../hooks/useComponentDidUpdat import { useEndpointData } from '../../../../../hooks/useEndpointData'; import { useForm } from '../../../../../hooks/useForm'; import { createToken } from '../../../../../lib/utils/createToken'; -import { formsSubscription } from '../../../additionalForms'; +import { useFormsSubscription } from '../../../additionalForms'; import { FormSkeleton } from '../../Skeleton'; const initialValues = { @@ -50,7 +49,7 @@ function ContactNewEdit({ id, data, close }) { const { values, handlers, hasUnsavedChanges: hasUnsavedChangesContact } = useForm(getInitialValues(data)); - const eeForms = useSubscription(formsSubscription); + const eeForms = useFormsSubscription(); const { useContactManager = () => {} } = eeForms; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js index 71c2edee9c54..c1cf1b438f8b 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js @@ -52,7 +52,7 @@ const AgentStatusChart = ({ params, reloadRef, ...props }) => { }, [t]); useEffect(() => { - if (state === AsyncStatePhase.RESOLVED) { + if (state === AsyncStatePhase.RESOLVED && context.current) { updateChartData(t('Offline'), [offline]); updateChartData(t('Available'), [available]); updateChartData(t('Away'), [away]); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js index f1dc8f1072d9..f049480b789a 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatDurationChart.js @@ -13,13 +13,13 @@ import { useUpdateChartData } from './useUpdateChartData'; const [labels, initialData] = getMomentChartLabelsAndData(); const tooltipCallbacks = { callbacks: { - title(tooltipItem, data) { - return data.labels[tooltipItem[0].index]; + title([ctx]) { + const { dataset } = ctx; + return dataset.label; }, - label(tooltipItem, data) { - const { datasetIndex, index } = tooltipItem; - const { data: datasetData, label } = data.datasets[datasetIndex]; - return `${label}: ${secondsToHHMMSS(datasetData[index])}`; + label(ctx) { + const { dataset, dataIndex } = ctx; + return `${dataset.label}: ${secondsToHHMMSS(dataset.data[dataIndex])}`; }, }, }; diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js index 4a2bbccab60e..a2e6837e9486 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsPerAgentChart.js @@ -9,6 +9,7 @@ import { useUpdateChartData } from './useUpdateChartData'; const initialData = { agents: {}, + success: true, }; const init = (canvas, context, t) => diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js index 04a2cf1af0b7..c94b59ee077e 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ResponseTimesChart.js @@ -13,13 +13,13 @@ import { useUpdateChartData } from './useUpdateChartData'; const [labels, initialData] = getMomentChartLabelsAndData(); const tooltipCallbacks = { callbacks: { - title(tooltipItem, data) { - return data.labels[tooltipItem[0].index]; + title([ctx]) { + const { dataset } = ctx; + return dataset.label; }, - label(tooltipItem, data) { - const { datasetIndex, index } = tooltipItem; - const { data: datasetData, label } = data.datasets[datasetIndex]; - return `${label}: ${secondsToHHMMSS(datasetData[index])}`; + label(ctx) { + const { dataset, dataIndex } = ctx; + return `${dataset.label}: ${secondsToHHMMSS(dataset.data[dataIndex])}`; }, }, }; diff --git a/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx b/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx index ea646b6b7452..c7def16dd3be 100644 --- a/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx +++ b/apps/meteor/client/views/omnichannel/sidebar/OmnichannelSidebar.tsx @@ -1,16 +1,16 @@ import { useRoutePath, useCurrentRoute, useTranslation } from '@rocket.chat/ui-contexts'; import React, { useCallback, useEffect, FC, memo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { menu, SideNav } from '../../../../app/ui-utils/client'; import Sidebar from '../../../components/Sidebar'; import SidebarItemsAssemblerProps from '../../../components/Sidebar/SidebarItemsAssembler'; import { isLayoutEmbedded } from '../../../lib/utils/isLayoutEmbedded'; import SettingsProvider from '../../../providers/SettingsProvider'; -import { itemsSubscription } from '../sidebarItems'; +import { getOmnichannelSidebarItems, subscribeToOmnichannelSidebarItems } from '../sidebarItems'; const OmnichannelSidebar: FC = () => { - const items = useSubscription(itemsSubscription); + const items = useSyncExternalStore(subscribeToOmnichannelSidebarItems, getOmnichannelSidebarItems); const t = useTranslation(); const closeOmnichannelFlex = useCallback(() => { diff --git a/apps/meteor/client/views/omnichannel/sidebarItems.ts b/apps/meteor/client/views/omnichannel/sidebarItems.ts index 34e815a3effb..2cda8c2c9e33 100644 --- a/apps/meteor/client/views/omnichannel/sidebarItems.ts +++ b/apps/meteor/client/views/omnichannel/sidebarItems.ts @@ -4,7 +4,8 @@ import { createSidebarItems } from '../../lib/createSidebarItems'; export const { registerSidebarItem: registerOmnichannelSidebarItem, unregisterSidebarItem, - itemsSubscription, + getSidebarItems: getOmnichannelSidebarItems, + subscribeToSidebarItems: subscribeToOmnichannelSidebarItems, } = createSidebarItems([ { href: 'omnichannel/current', diff --git a/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx b/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx index c96c6a92075d..f9fda919d88c 100644 --- a/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx +++ b/apps/meteor/client/views/room/MessageList/contexts/SelectedMessagesContext.tsx @@ -1,6 +1,5 @@ -import { OffCallbackHandler } from '@rocket.chat/emitter'; -import { createContext, useCallback, useContext, useMemo } from 'react'; -import { useSubscription } from 'use-subscription'; +import { createContext, useCallback, useContext } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { selectedMessageStore } from '../../providers/SelectedMessagesProvider'; @@ -14,28 +13,28 @@ export const SelectedMessageContext = createContext({ export const useIsSelectedMessage = (mid: string): boolean => { const { selectedMessageStore } = useContext(SelectedMessageContext); - const subscription = useMemo( - () => ({ - getCurrentValue: (): boolean => selectedMessageStore.isSelected(mid), - subscribe: (callback: () => void): OffCallbackHandler => selectedMessageStore.on(mid, callback), - }), - [mid, selectedMessageStore], + + const subscribe = useCallback( + (callback: () => void): (() => void) => selectedMessageStore.on(mid, callback), + [selectedMessageStore, mid], ); - return useSubscription(subscription); + + const getSnapshot = (): boolean => selectedMessageStore.isSelected(mid); + + return useSyncExternalStore(subscribe, getSnapshot); }; export const useIsSelecting = (): boolean => { const { selectedMessageStore } = useContext(SelectedMessageContext); - return useSubscription( - useMemo( - () => ({ - getCurrentValue: (): boolean => selectedMessageStore.getIsSelecting(), - subscribe: (callback: () => void): OffCallbackHandler => selectedMessageStore.on('toggleIsSelecting', callback), - }), - [selectedMessageStore], - ), + const subscribe = useCallback( + (callback: () => void): (() => void) => selectedMessageStore.on('toggleIsSelecting', callback), + [selectedMessageStore], ); + + const getSnapshot = (): boolean => selectedMessageStore.getIsSelecting(); + + return useSyncExternalStore(subscribe, getSnapshot); }; export const useToggleSelect = (mid: string): (() => void) => { @@ -48,13 +47,12 @@ export const useToggleSelect = (mid: string): (() => void) => { export const useCountSelected = (): number => { const { selectedMessageStore } = useContext(SelectedMessageContext); - return useSubscription( - useMemo( - () => ({ - getCurrentValue: (): number => selectedMessageStore.count(), - subscribe: (callback: () => void): OffCallbackHandler => selectedMessageStore.on('change', callback), - }), - [selectedMessageStore], - ), + const subscribe = useCallback( + (callback: () => void): (() => void) => selectedMessageStore.on('change', callback), + [selectedMessageStore], ); + + const getSnapshot = (): number => selectedMessageStore.count(); + + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx b/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx index 3d23ca9972ec..a9aec5439b62 100644 --- a/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx +++ b/apps/meteor/client/views/room/MessageList/providers/MessageHighlightProvider.tsx @@ -1,11 +1,11 @@ import React, { ReactElement, ContextType, useMemo, ReactNode } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import MessageHighlightContext from '../contexts/MessageHighlightContext'; -import { messageHighlightSubscription } from './messageHighlightSubscription'; +import * as messageHighlightSubscription from './messageHighlightSubscription'; const MessageHighlightProvider = ({ children }: { children: ReactNode }): ReactElement => { - const highlightMessageId = useSubscription(messageHighlightSubscription); + const highlightMessageId = useSyncExternalStore(messageHighlightSubscription.subscribe, messageHighlightSubscription.getSnapshot); const contextValue = useMemo>( () => ({ diff --git a/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts b/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts index 31fcb3bab269..dff96e0b0105 100644 --- a/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts +++ b/apps/meteor/client/views/room/MessageList/providers/messageHighlightSubscription.ts @@ -1,31 +1,29 @@ import { IMessage } from '@rocket.chat/core-typings'; -import { Subscription, Unsubscribe } from 'use-subscription'; type SetHighlightFn = (_id: IMessage['_id']) => void; type ClearHighlightFn = (_id: IMessage['_id']) => void; type MessageHighlightSubscription = { - subscription: Subscription; + subscribe: (callback: () => void) => () => void; + getSnapshot: () => IMessage['_id'] | undefined; setHighlight: SetHighlightFn; clearHighlight: ClearHighlightFn; }; const createMessageHighlightSubscription = (): MessageHighlightSubscription => { - let updateCb: Unsubscribe = () => undefined; + let updateCb: () => void = () => undefined; let highlightMessageId: IMessage['_id'] | undefined; - const subscription: Subscription = { - subscribe: (cb) => { - updateCb = cb; - return (): void => { - updateCb = (): void => undefined; - }; - }, - - getCurrentValue: (): typeof highlightMessageId => highlightMessageId, + const subscribe = (cb: () => void): (() => void) => { + updateCb = cb; + return (): void => { + updateCb = (): void => undefined; + }; }; + const getSnapshot = (): typeof highlightMessageId => highlightMessageId; + const setHighlight = (_id: IMessage['_id']): void => { highlightMessageId = _id; updateCb(); @@ -36,11 +34,12 @@ const createMessageHighlightSubscription = (): MessageHighlightSubscription => { updateCb(); }; - return { subscription, setHighlight, clearHighlight }; + return { subscribe, getSnapshot, setHighlight, clearHighlight }; }; export const { - subscription: messageHighlightSubscription, + getSnapshot, + subscribe, setHighlight: setHighlightMessage, clearHighlight: clearHighlightMessage, } = createMessageHighlightSubscription(); diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx index 0ee902a32686..e45f0f55158d 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.stories.tsx @@ -17,5 +17,5 @@ export default { export const Default: ComponentStory = (args) => ; Default.storyName = 'AddUsers'; Default.args = { - value: 'rocket.cat', + users: ['rocket.cat'], }; diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.js b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx similarity index 61% rename from apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.js rename to apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx index a6f3db83a52e..ae39742aac61 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.js +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsers.tsx @@ -1,11 +1,20 @@ +import { IUser } from '@rocket.chat/core-typings'; import { Field, Button } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import React, { ReactElement } from 'react'; import UserAutoCompleteMultiple from '../../../../../components/UserAutoCompleteMultiple'; import VerticalBar from '../../../../../components/VerticalBar'; -const AddUsers = ({ onClickClose, onClickBack, onClickSave, value, onChange }) => { +type AddUsersProps = { + onClickClose?: () => void; + onClickBack?: () => void; + onClickSave: () => Promise; + users: IUser['username'][]; + onChange: (value: IUser['username'][], action?: string) => void; +}; + +const AddUsers = ({ onClickClose, onClickBack, onClickSave, users, onChange }: AddUsersProps): ReactElement => { const t = useTranslation(); return ( @@ -18,11 +27,11 @@ const AddUsers = ({ onClickClose, onClickBack, onClickSave, value, onChange }) = {t('Choose_users')} - + - diff --git a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.js b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.tsx similarity index 67% rename from apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.js rename to apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.tsx index 90bc58d23dfd..e088874417c5 100644 --- a/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.js +++ b/apps/meteor/client/views/room/contextualBar/RoomMembers/AddUsers/AddUsersWithData.tsx @@ -1,20 +1,31 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; -import React from 'react'; +import React, { ReactElement } from 'react'; import { useForm } from '../../../../../hooks/useForm'; import { useTabBarClose } from '../../../providers/ToolboxProvider'; import AddUsers from './AddUsers'; -const AddUsersWithData = ({ rid, onClickBack, reload }) => { +type AddUsersWithDataProps = { + rid: IRoom['_id']; + onClickBack: () => void; + reload: () => void; +}; + +type AddUsersInitialProps = { + users: IUser['username'][]; +}; + +const AddUsersWithData = ({ rid, onClickBack, reload }: AddUsersWithDataProps): ReactElement => { const t = useTranslation(); const dispatchToastMessage = useToastMessageDispatch(); const onClickClose = useTabBarClose(); const saveAction = useMethod('addUsersToRoom'); - const { values, handlers } = useForm({ users: [] }); - const { users } = values; + const { values, handlers } = useForm({ users: [] as IUser['username'][] }); + const { users } = values as AddUsersInitialProps; const { handleUsers } = handlers; const onChangeUsers = useMutableCallback((value, action) => { @@ -38,7 +49,7 @@ const AddUsersWithData = ({ rid, onClickBack, reload }) => { } }); - return ; + return ; }; export default AddUsersWithData; diff --git a/apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts b/apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts new file mode 100644 index 000000000000..cc9e93bdf318 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserHasRoomRole.ts @@ -0,0 +1,8 @@ +import { IRole, IRoom, IUser } from '@rocket.chat/core-typings'; +import { useCallback } from 'react'; + +import { RoomRoles } from '../../../../app/models/client'; +import { useReactiveValue } from '../../../hooks/useReactiveValue'; + +export const useUserHasRoomRole = (uid: IUser['_id'], rid: IRoom['_id'], role: IRole['name']): boolean => + useReactiveValue(useCallback(() => !!RoomRoles.findOne({ rid, 'u._id': uid, 'roles': role }), [uid, rid, role])); diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions.js b/apps/meteor/client/views/room/hooks/useUserInfoActions.js deleted file mode 100644 index fc6fdabd4a1f..000000000000 --- a/apps/meteor/client/views/room/hooks/useUserInfoActions.js +++ /dev/null @@ -1,415 +0,0 @@ -import { Button, ButtonGroup, Icon, Modal, Box } from '@rocket.chat/fuselage'; -import { useAutoFocus, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { escapeHTML } from '@rocket.chat/string-helpers'; -import { - useSetModal, - useToastMessageDispatch, - useRoute, - useUserId, - useUserSubscription, - useUserRoom, - useUserSubscriptionByName, - usePermission, - useAllPermissions, - useMethod, - useTranslation, -} from '@rocket.chat/ui-contexts'; -import React, { useCallback, useMemo } from 'react'; - -import { RoomRoles } from '../../../../app/models/client'; -import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; -import { useEndpointActionExperimental } from '../../../hooks/useEndpointActionExperimental'; -import { useReactiveValue } from '../../../hooks/useReactiveValue'; -import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; -import RemoveUsersModal from '../../teams/contextualBar/members/RemoveUsersModal'; -import { useWebRTC } from './useWebRTC'; - -const useUserHasRoomRole = (uid, rid, role) => - useReactiveValue(useCallback(() => !!RoomRoles.findOne({ rid, 'u._id': uid, 'roles': role }), [uid, rid, role])); - -const getShouldOpenDirectMessage = (currentSubscription, usernameSubscription, canOpenDirectMessage, username) => { - const canOpenDm = canOpenDirectMessage || usernameSubscription; - const directMessageIsNotAlreadyOpen = currentSubscription && currentSubscription.name !== username; - return canOpenDm && directMessageIsNotAlreadyOpen; -}; - -const getUserIsMuted = (room, user, userCanPostReadonly) => { - if (room && room.ro) { - if (Array.isArray(room.unmuted) && room.unmuted.indexOf(user && user.username) !== -1) { - return false; - } - - if (userCanPostReadonly) { - return Array.isArray(room.muted) && room.muted.indexOf(user && user.username) !== -1; - } - - return true; - } - - return room && Array.isArray(room.muted) && room.muted.indexOf(user && user.username) > -1; -}; - -const WarningModal = ({ text, confirmText, close, confirm, ...props }) => { - const refAutoFocus = useAutoFocus(true); - const t = useTranslation(); - return ( - - - - {t('Are_you_sure')} - - - {text} - - - - - - - - ); -}; -// TODO: Remove endpoint concatenation -export const useUserInfoActions = (user = {}, rid, reload) => { - const t = useTranslation(); - const dispatchToastMessage = useToastMessageDispatch(); - const directRoute = useRoute('direct'); - - const setModal = useSetModal(); - - const { _id: uid } = user; - const ownUserId = useUserId(); - - const closeModal = useMutableCallback(() => setModal(null)); - - const room = useUserRoom(rid); - const currentSubscription = useUserSubscription(rid); - const usernameSubscription = useUserSubscriptionByName(user.username); - - const isLeader = useUserHasRoomRole(uid, rid, 'leader'); - const isModerator = useUserHasRoomRole(uid, rid, 'moderator'); - const isOwner = useUserHasRoomRole(uid, rid, 'owner'); - - const otherUserCanPostReadonly = useAllPermissions('post-readonly', rid); - - const isIgnored = currentSubscription && currentSubscription.ignored && currentSubscription.ignored.indexOf(uid) > -1; - const isMuted = getUserIsMuted(room, user, otherUserCanPostReadonly); - - const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; - - const roomDirectives = room && room.t && roomCoordinator.getRoomDirectives(room.t); - - const [roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove] = [ - ...(roomDirectives && [ - roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_OWNER), - roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_LEADER), - roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_MODERATOR), - roomDirectives.allowMemberAction(room, RoomMemberActions.IGNORE), - roomDirectives.allowMemberAction(room, RoomMemberActions.BLOCK), - roomDirectives.allowMemberAction(room, RoomMemberActions.MUTE), - roomDirectives.allowMemberAction(room, RoomMemberActions.REMOVE_USER), - ]), - ]; - - const roomName = room && room.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); - - const userCanSetOwner = usePermission('set-owner', rid); - const userCanSetLeader = usePermission('set-leader', rid); - const userCanSetModerator = usePermission('set-moderator', rid); - const userCanMute = usePermission('mute-user', rid); - const userCanRemove = usePermission('remove-user', rid); - const userCanDirectMessage = usePermission('create-d'); - const { shouldAllowCalls, callInProgress, joinCall, startCall } = useWebRTC(rid); - - const shouldOpenDirectMessage = getShouldOpenDirectMessage( - currentSubscription, - usernameSubscription, - userCanDirectMessage, - user.username, - ); - - const openDirectDm = useMutableCallback(() => - directRoute.push({ - rid: user.username, - }), - ); - - const openDirectMessageOption = useMemo( - () => - shouldOpenDirectMessage && { - label: t('Direct_Message'), - icon: 'balloon', - action: openDirectDm, - }, - [openDirectDm, shouldOpenDirectMessage, t], - ); - - const videoCallOption = useMemo(() => { - const handleJoinCall = () => { - joinCall({ audio: true, video: true }); - }; - const handleStartCall = () => { - startCall({ audio: true, video: true }); - }; - const action = callInProgress ? handleJoinCall : handleStartCall; - - return ( - shouldAllowCalls && { - label: t(callInProgress ? 'Join_video_call' : 'Start_video_call'), - icon: 'video', - action, - } - ); - }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); - - const audioCallOption = useMemo(() => { - const handleJoinCall = () => { - joinCall({ audio: true, video: false }); - }; - const handleStartCall = () => { - startCall({ audio: true, video: false }); - }; - const action = callInProgress ? handleJoinCall : handleStartCall; - - return ( - shouldAllowCalls && { - label: t(callInProgress ? 'Join_audio_call' : 'Start_audio_call'), - icon: 'mic', - action, - } - ); - }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); - - const changeOwnerEndpoint = isOwner ? 'removeOwner' : 'addOwner'; - const changeOwnerMessage = isOwner ? 'User__username__removed_from__room_name__owners' : 'User__username__is_now_a_owner_of__room_name_'; - const changeOwner = useEndpointActionExperimental( - 'POST', - `${endpointPrefix}.${changeOwnerEndpoint}`, - t(changeOwnerMessage, { username: user.username, room_name: roomName }), - ); - const changeOwnerAction = useMutableCallback(async () => changeOwner({ roomId: rid, userId: uid })); - const changeOwnerOption = useMemo( - () => - roomCanSetOwner && - userCanSetOwner && { - label: t(isOwner ? 'Remove_as_owner' : 'Set_as_owner'), - icon: 'shield-check', - action: changeOwnerAction, - }, - [changeOwnerAction, isOwner, t, roomCanSetOwner, userCanSetOwner], - ); - - const changeLeaderEndpoint = isLeader ? 'removeLeader' : 'addLeader'; - const changeLeaderMessage = isLeader - ? 'User__username__removed_from__room_name__leaders' - : 'User__username__is_now_a_leader_of__room_name_'; - const changeLeader = useEndpointActionExperimental( - 'POST', - `${endpointPrefix}.${changeLeaderEndpoint}`, - t(changeLeaderMessage, { username: user.username, room_name: roomName }), - ); - const changeLeaderAction = useMutableCallback(() => changeLeader({ roomId: rid, userId: uid })); - const changeLeaderOption = useMemo( - () => - roomCanSetLeader && - userCanSetLeader && { - label: t(isLeader ? 'Remove_as_leader' : 'Set_as_leader'), - icon: 'shield-alt', - action: changeLeaderAction, - }, - [isLeader, roomCanSetLeader, t, userCanSetLeader, changeLeaderAction], - ); - - const changeModeratorEndpoint = isModerator ? 'removeModerator' : 'addModerator'; - const changeModeratorMessage = isModerator - ? 'User__username__removed_from__room_name__moderators' - : 'User__username__is_now_a_moderator_of__room_name_'; - const changeModerator = useEndpointActionExperimental( - 'POST', - `${endpointPrefix}.${changeModeratorEndpoint}`, - t(changeModeratorMessage, { username: user.username, room_name: roomName }), - ); - const changeModeratorAction = useMutableCallback(() => changeModerator({ roomId: rid, userId: uid })); - const changeModeratorOption = useMemo( - () => - roomCanSetModerator && - userCanSetModerator && { - label: t(isModerator ? 'Remove_as_moderator' : 'Set_as_moderator'), - icon: 'shield', - action: changeModeratorAction, - }, - [changeModeratorAction, isModerator, roomCanSetModerator, t, userCanSetModerator], - ); - - const ignoreUser = useMethod('ignoreUser'); - const ignoreUserAction = useMutableCallback(async () => { - try { - await ignoreUser({ rid, userId: uid, ignore: !isIgnored }); - if (isIgnored) { - dispatchToastMessage({ type: 'success', message: t('User_has_been_unignored') }); - } else { - dispatchToastMessage({ type: 'success', message: t('User_has_been_ignored') }); - } - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - const ignoreUserOption = useMemo( - () => - roomCanIgnore && - uid !== ownUserId && { - label: t(isIgnored ? 'Unignore' : 'Ignore'), - icon: 'ban', - action: ignoreUserAction, - }, - [ignoreUserAction, isIgnored, ownUserId, roomCanIgnore, t, uid], - ); - - const isUserBlocked = currentSubscription && currentSubscription.blocker; - const toggleBlock = useMethod(isUserBlocked ? 'unblockUser' : 'blockUser'); - const toggleBlockUserAction = useMutableCallback(async () => { - try { - await toggleBlock({ rid, blocked: uid }); - dispatchToastMessage({ - type: 'success', - message: t(isUserBlocked ? 'User_is_unblocked' : 'User_is_blocked'), - }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }); - const toggleBlockUserOption = useMemo( - () => - roomCanBlock && - uid !== ownUserId && { - label: t(isUserBlocked ? 'Unblock' : 'Block'), - icon: 'ban', - action: toggleBlockUserAction, - }, - [isUserBlocked, ownUserId, roomCanBlock, t, toggleBlockUserAction, uid], - ); - - const muteFn = useMethod(isMuted ? 'unmuteUserInRoom' : 'muteUserInRoom'); - const muteUserOption = useMemo(() => { - const action = () => { - const onConfirm = async () => { - try { - await muteFn({ rid, username: user.username }); - closeModal(); - dispatchToastMessage({ - type: 'success', - message: t(isMuted ? 'User__username__unmuted_in_room__roomName__' : 'User__username__muted_in_room__roomName__', { - username: user.username, - roomName, - }), - }); - } catch (error) { - dispatchToastMessage({ type: 'error', message: error }); - } - }; - - if (isMuted) { - return onConfirm(); - } - - setModal( - , - ); - }; - - return ( - roomCanMute && - userCanMute && { - label: t(isMuted ? 'Unmute_user' : 'Mute_user'), - icon: isMuted ? 'mic' : 'mic-off', - action, - } - ); - }, [closeModal, dispatchToastMessage, isMuted, muteFn, rid, roomCanMute, roomName, setModal, t, user.username, userCanMute]); - - const removeFromTeam = useEndpointActionExperimental('POST', 'teams.removeMember', t('User_has_been_removed_from_team')); - - const removeUserAction = useEndpointActionExperimental('POST', `${endpointPrefix}.kick`, t('User_has_been_removed_from_s', roomName)); - const removeUserOptionAction = useMutableCallback(() => { - if (room.teamMain && room.teamId) { - return setModal( - { - const roomKeys = Object.keys(rooms); - await removeFromTeam({ - teamId: room.teamId, - userId: uid, - ...(roomKeys.length && { rooms: roomKeys }), - }); - closeModal(); - reload && reload(); - }} - />, - ); - } - - setModal( - { - await removeUserAction({ roomId: rid, userId: uid }); - closeModal(); - reload && reload(); - }} - />, - ); - }); - - const removeUserOption = useMemo( - () => - roomCanRemove && - userCanRemove && { - label: {room.teamMain ? t('Remove_from_team') : t('Remove_from_room')}, - icon: 'sign-out', - action: removeUserOptionAction, - }, - [room, roomCanRemove, userCanRemove, removeUserOptionAction, t], - ); - - return useMemo( - () => ({ - ...(openDirectMessageOption && { openDirectMessage: openDirectMessageOption }), - ...(videoCallOption && { video: videoCallOption }), - ...(audioCallOption && { audio: audioCallOption }), - ...(changeOwnerOption && { changeOwner: changeOwnerOption }), - ...(changeLeaderOption && { changeLeader: changeLeaderOption }), - ...(changeModeratorOption && { changeModerator: changeModeratorOption }), - ...(ignoreUserOption && { ignoreUser: ignoreUserOption }), - ...(muteUserOption && { muteUser: muteUserOption }), - ...(removeUserOption && { removeUser: removeUserOption }), - ...(toggleBlockUserOption && { toggleBlock: toggleBlockUserOption }), - }), - [ - audioCallOption, - changeLeaderOption, - changeModeratorOption, - changeOwnerOption, - ignoreUserOption, - muteUserOption, - openDirectMessageOption, - removeUserOption, - videoCallOption, - toggleBlockUserOption, - ], - ); -}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAudioCallAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAudioCallAction.ts new file mode 100644 index 000000000000..db92ce2f6895 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useAudioCallAction.ts @@ -0,0 +1,33 @@ +import { IRoom } from '@rocket.chat/core-typings'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { useWebRTC } from '../../useWebRTC'; + +export const useAudioCallAction = (rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const { shouldAllowCalls, callInProgress, joinCall, startCall } = useWebRTC(rid); + + const audioCallOption = useMemo(() => { + const handleJoinCall = (): void => { + joinCall({ audio: true, video: false }); + }; + + const handleStartCall = (): void => { + startCall({ audio: true, video: false }); + }; + + const action = callInProgress ? handleJoinCall : handleStartCall; + + return shouldAllowCalls + ? { + label: t(callInProgress ? 'Join_audio_call' : 'Start_audio_call'), + icon: 'mic', + action, + } + : undefined; + }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); + + return audioCallOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts new file mode 100644 index 000000000000..0a0574d6b762 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useBlockUserAction.ts @@ -0,0 +1,51 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useMethod, useToastMessageDispatch, useUserId, useUserSubscription, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +export const useBlockUserAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + const currentSubscription = useUserSubscription(rid); + const ownUserId = useUserId(); + const { _id: uid } = user; + const room = useUserRoom(rid); + + if (!room) { + throw Error('Room not provided'); + } + + const { roomCanBlock } = getRoomDirectives(room); + + const isUserBlocked = currentSubscription?.blocker; + const toggleBlock = useMethod(isUserBlocked ? 'unblockUser' : 'blockUser'); + + const toggleBlockUserAction = useMutableCallback(async () => { + try { + await toggleBlock({ rid, blocked: uid }); + dispatchToastMessage({ + type: 'success', + message: t(isUserBlocked ? 'User_is_unblocked' : 'User_is_blocked'), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const toggleBlockUserOption = useMemo( + () => + roomCanBlock && uid !== ownUserId + ? { + label: t(isUserBlocked ? 'Unblock' : 'Block'), + icon: 'ban', + action: toggleBlockUserAction, + } + : undefined, + [isUserBlocked, ownUserId, roomCanBlock, t, toggleBlockUserAction, uid], + ); + + return toggleBlockUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeLeaderAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeLeaderAction.ts new file mode 100644 index 000000000000..dddfc28397e3 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeLeaderAction.ts @@ -0,0 +1,53 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { useTranslation, usePermission, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import { useUserHasRoomRole } from '../../useUserHasRoomRole'; + +// TODO: Remove endpoint concatenation +export const useChangeLeaderAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + const userCanSetLeader = usePermission('set-leader', rid); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanSetLeader } = getRoomDirectives(room); + const isLeader = useUserHasRoomRole(uid, rid, 'leader'); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + const changeLeaderEndpoint = isLeader ? 'removeLeader' : 'addLeader'; + const changeLeaderMessage = isLeader + ? 'User__username__removed_from__room_name__leaders' + : 'User__username__is_now_a_leader_of__room_name_'; + const changeLeader = useEndpointActionExperimental( + 'POST', + `${endpointPrefix}.${changeLeaderEndpoint}`, + // eslint-disable-next-line @typescript-eslint/camelcase + t(changeLeaderMessage, { username: user.username, room_name: roomName }), + ); + const changeLeaderAction = useMutableCallback(() => changeLeader({ roomId: rid, userId: uid })); + const changeLeaderOption = useMemo( + () => + roomCanSetLeader && userCanSetLeader + ? { + label: t(isLeader ? 'Remove_as_leader' : 'Set_as_leader'), + icon: 'shield-alt', + action: changeLeaderAction, + } + : undefined, + [isLeader, roomCanSetLeader, t, userCanSetLeader, changeLeaderAction], + ); + + return changeLeaderOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeModeratorAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeModeratorAction.ts new file mode 100644 index 000000000000..0b32928789aa --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeModeratorAction.ts @@ -0,0 +1,54 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { useTranslation, usePermission, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import { useUserHasRoomRole } from '../../useUserHasRoomRole'; + +// TODO: Remove endpoint concatenation +export const useChangeModeratorAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + + const userCanSetModerator = usePermission('set-moderator', rid); + const isModerator = useUserHasRoomRole(uid, rid, 'moderator'); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanSetModerator } = getRoomDirectives(room); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + const changeModeratorEndpoint = isModerator ? 'removeModerator' : 'addModerator'; + const changeModeratorMessage = isModerator + ? 'User__username__removed_from__room_name__moderators' + : 'User__username__is_now_a_moderator_of__room_name_'; + const changeModerator = useEndpointActionExperimental( + 'POST', + `${endpointPrefix}.${changeModeratorEndpoint}`, + // eslint-disable-next-line @typescript-eslint/camelcase + t(changeModeratorMessage, { username: user.username, room_name: roomName }), + ); + const changeModeratorAction = useMutableCallback(() => changeModerator({ roomId: rid, userId: uid })); + const changeModeratorOption = useMemo( + () => + roomCanSetModerator && userCanSetModerator + ? { + label: t(isModerator ? 'Remove_as_moderator' : 'Set_as_moderator'), + icon: 'shield-blank', + action: changeModeratorAction, + } + : undefined, + [changeModeratorAction, isModerator, roomCanSetModerator, t, userCanSetModerator], + ); + + return changeModeratorOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeOwnerAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeOwnerAction.tsx new file mode 100644 index 000000000000..4e027c0c150d --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useChangeOwnerAction.tsx @@ -0,0 +1,53 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { useTranslation, usePermission, useUserRoom } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; +import { useUserHasRoomRole } from '../../useUserHasRoomRole'; + +// TODO: Remove endpoint concatenation +export const useChangeOwnerAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + const userCanSetOwner = usePermission('set-owner', rid); + const isOwner = useUserHasRoomRole(uid, rid, 'owner'); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanSetOwner } = getRoomDirectives(room); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + const changeOwnerEndpoint = isOwner ? 'removeOwner' : 'addOwner'; + const changeOwnerMessage = isOwner ? 'User__username__removed_from__room_name__owners' : 'User__username__is_now_a_owner_of__room_name_'; + + const changeOwner = useEndpointActionExperimental( + 'POST', + `${endpointPrefix}.${changeOwnerEndpoint}`, + // eslint-disable-next-line @typescript-eslint/camelcase + t(changeOwnerMessage, { username: user.username, room_name: roomName }), + ); + + const changeOwnerAction = useMutableCallback(async () => changeOwner({ roomId: rid, userId: uid })); + const changeOwnerOption = useMemo( + () => + roomCanSetOwner && userCanSetOwner + ? { + label: t(isOwner ? 'Remove_as_owner' : 'Set_as_owner'), + icon: 'shield-check', + action: changeOwnerAction, + } + : undefined, + [changeOwnerAction, roomCanSetOwner, userCanSetOwner, isOwner, t], + ); + + return changeOwnerOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useDirectMessageAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useDirectMessageAction.ts new file mode 100644 index 000000000000..07f07a78d328 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useDirectMessageAction.ts @@ -0,0 +1,54 @@ +import { IRoom, IUser, ISubscription } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, usePermission, useRoute, useUserSubscription, useUserSubscriptionByName } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; + +const getShouldOpenDirectMessage = ( + currentSubscription?: ISubscription, + usernameSubscription?: ISubscription, + canOpenDirectMessage?: boolean, + username?: IUser['username'], +): boolean => { + const canOpenDm = canOpenDirectMessage || usernameSubscription; + const directMessageIsNotAlreadyOpen = currentSubscription && currentSubscription.name !== username; + return (canOpenDm && directMessageIsNotAlreadyOpen) ?? false; +}; + +export const useDirectMessageAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const usernameSubscription = useUserSubscriptionByName(user.username ?? ''); + const currentSubscription = useUserSubscription(rid); + const canOpenDirectMessage = usePermission('create-d'); + const directRoute = useRoute('direct'); + + const shouldOpenDirectMessage = getShouldOpenDirectMessage( + currentSubscription, + usernameSubscription, + canOpenDirectMessage, + user.username, + ); + + const openDirectMessage = useMutableCallback( + () => + user.username && + directRoute.push({ + rid: user.username, + }), + ); + + const openDirectMessageOption = useMemo( + () => + shouldOpenDirectMessage + ? { + label: t('Direct_Message'), + icon: 'balloon', + action: openDirectMessage, + } + : undefined, + [openDirectMessage, shouldOpenDirectMessage, t], + ); + + return openDirectMessageOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useIgnoreUserAction.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useIgnoreUserAction.ts new file mode 100644 index 000000000000..8147c461ef8d --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useIgnoreUserAction.ts @@ -0,0 +1,52 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useTranslation, useMethod, useUserSubscription, useUserRoom, useUserId, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +export const useIgnoreUserAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + const ownUserId = useUserId(); + const dispatchToastMessage = useToastMessageDispatch(); + const currentSubscription = useUserSubscription(rid); + const ignoreUser = useMethod('ignoreUser'); + + const isIgnored = currentSubscription?.ignored && currentSubscription.ignored.indexOf(uid) > -1; + + if (!room) { + throw Error('Room not provided'); + } + + const { roomCanIgnore } = getRoomDirectives(room); + + const ignoreUserAction = useMutableCallback(async () => { + try { + await ignoreUser({ rid, userId: uid, ignore: !isIgnored }); + if (isIgnored) { + dispatchToastMessage({ type: 'success', message: t('User_has_been_unignored') }); + } else { + dispatchToastMessage({ type: 'success', message: t('User_has_been_ignored') }); + } + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } + }); + + const ignoreUserOption = useMemo( + () => + roomCanIgnore && uid !== ownUserId + ? { + label: t(isIgnored ? 'Unignore' : 'Ignore'), + icon: 'ban', + action: ignoreUserAction, + } + : undefined, + [ignoreUserAction, isIgnored, ownUserId, roomCanIgnore, t, uid], + ); + + return ignoreUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useMuteUserAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useMuteUserAction.tsx new file mode 100644 index 000000000000..b3fc6fe926b4 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useMuteUserAction.tsx @@ -0,0 +1,119 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { + useAllPermissions, + usePermission, + useSetModal, + useMethod, + useToastMessageDispatch, + useTranslation, + useUserRoom, +} from '@rocket.chat/ui-contexts'; +import React, { useMemo } from 'react'; + +import GenericModal from '../../../../../components/GenericModal'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +const getUserIsMuted = ( + user: Pick, + room: IRoom | undefined, + userCanPostReadonly: boolean, +): boolean | undefined => { + if (room?.ro) { + if (Array.isArray(room.unmuted) && room.unmuted.indexOf(user.username ?? '') !== -1) { + return false; + } + + if (userCanPostReadonly) { + return Array.isArray(room.muted) && room.muted.indexOf(user.username ?? '') !== -1; + } + + return true; + } + + return room && Array.isArray(room.muted) && room.muted.indexOf(user.username ?? '') > -1; +}; + +export const useMuteUserAction = (user: Pick, rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const userCanMute = usePermission('mute-user', rid); + const dispatchToastMessage = useToastMessageDispatch(); + const setModal = useSetModal(); + const closeModal = useMutableCallback(() => setModal(null)); + const otherUserCanPostReadonly = useAllPermissions( + useMemo(() => ['post-readonly'], []), + rid, + ); + + const isMuted = getUserIsMuted(user, room, otherUserCanPostReadonly); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + if (!room) { + throw Error('Room not provided'); + } + + const { roomCanMute } = getRoomDirectives(room); + + const mutedMessage = isMuted ? 'User__username__unmuted_in_room__roomName__' : 'User__username__muted_in_room__roomName__'; + + const muteUser = useMethod(isMuted ? 'unmuteUserInRoom' : 'muteUserInRoom'); + + const muteUserOption = useMemo(() => { + const action = (): Promise | void => { + const onConfirm = async (): Promise => { + try { + await muteUser({ rid, username: user.username }); + + return dispatchToastMessage({ + type: 'success', + message: t(mutedMessage, { + username: user.username, + roomName, + }), + }); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + } finally { + closeModal(); + } + }; + + if (isMuted) { + return onConfirm(); + } + + return setModal( + + {t('The_user_wont_be_able_to_type_in_s', roomName)} + , + ); + }; + + return roomCanMute && userCanMute + ? { + label: t(isMuted ? 'Unmute_user' : 'Mute_user'), + icon: isMuted ? 'mic' : 'mic-off', + action, + } + : undefined; + }, [ + closeModal, + mutedMessage, + dispatchToastMessage, + isMuted, + muteUser, + rid, + roomCanMute, + roomName, + setModal, + t, + user.username, + userCanMute, + ]); + + return muteUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useRemoveUserAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useRemoveUserAction.tsx new file mode 100644 index 000000000000..6bc41e267db0 --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useRemoveUserAction.tsx @@ -0,0 +1,92 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Box, Icon } from '@rocket.chat/fuselage'; +import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { escapeHTML } from '@rocket.chat/string-helpers'; +import { usePermission, useSetModal, useTranslation, useUserRoom } from '@rocket.chat/ui-contexts'; +import React, { useMemo } from 'react'; + +import GenericModal from '../../../../../components/GenericModal'; +import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointActionExperimental'; +import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator'; +import { Action } from '../../../../hooks/useActionSpread'; +import RemoveUsersModal from '../../../../teams/contextualBar/members/RemoveUsersModal'; +import { getRoomDirectives } from '../../../lib/getRoomDirectives'; + +// TODO: Remove endpoint concatenation +export const useRemoveUserAction = (user: Pick, rid: IRoom['_id'], reload?: () => void): Action | undefined => { + const t = useTranslation(); + const room = useUserRoom(rid); + const { _id: uid } = user; + + const userCanRemove = usePermission('remove-user', rid); + const setModal = useSetModal(); + const closeModal = useMutableCallback(() => setModal(null)); + const roomName = room?.t && escapeHTML(roomCoordinator.getRoomName(room.t, room)); + + if (!room) { + throw Error('Room not provided'); + } + + const endpointPrefix = room.t === 'p' ? '/v1/groups' : '/v1/channels'; + const { roomCanRemove } = getRoomDirectives(room); + + const removeFromTeam = useEndpointActionExperimental('POST', '/v1/teams.removeMember', t('User_has_been_removed_from_team')); + const removeFromRoom = useEndpointActionExperimental('POST', `${endpointPrefix}.kick`, t('User_has_been_removed_from_s', roomName)); + + const removeUserOptionAction = useMutableCallback(() => { + const handleRemoveFromTeam = async (rooms: IRoom[]): Promise => { + if (room.teamId) { + const roomKeys = Object.keys(rooms); + await removeFromTeam({ + teamId: room.teamId, + userId: uid, + ...(roomKeys.length && { rooms: roomKeys }), + }); + closeModal(); + reload?.(); + } + }; + + const handleRemoveFromRoom = async (rid: IRoom['_id'], uid: IUser['_id']): Promise => { + await removeFromRoom({ roomId: rid, userId: uid }); + closeModal(); + reload?.(); + }; + + if (room.teamMain && room.teamId) { + return setModal( + , + ); + } + + setModal( + => handleRemoveFromRoom(rid, uid)} + > + {t('The_user_will_be_removed_from_s', roomName)} + , + ); + }); + + const removeUserOption = useMemo( + () => + roomCanRemove && userCanRemove + ? { + label: ( + + + {room?.teamMain ? t('Remove_from_team') : t('Remove_from_room')} + + ), + action: removeUserOptionAction, + } + : undefined, + [room, roomCanRemove, userCanRemove, removeUserOptionAction, t], + ); + + return removeUserOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx new file mode 100644 index 000000000000..6a3fafcd8dbe --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVideoCallAction.tsx @@ -0,0 +1,33 @@ +import { IRoom } from '@rocket.chat/core-typings'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import { useMemo } from 'react'; + +import { Action } from '../../../../hooks/useActionSpread'; +import { useWebRTC } from '../../useWebRTC'; + +export const useVideoCallAction = (rid: IRoom['_id']): Action | undefined => { + const t = useTranslation(); + const { shouldAllowCalls, callInProgress, joinCall, startCall } = useWebRTC(rid); + + const videoCallOption = useMemo(() => { + const handleJoinCall = (): void => { + joinCall({ audio: true, video: true }); + }; + + const handleStartCall = (): void => { + startCall({ audio: true, video: true }); + }; + + const action = callInProgress ? handleJoinCall : handleStartCall; + + return shouldAllowCalls + ? { + label: t(callInProgress ? 'Join_video_call' : 'Start_video_call'), + icon: 'video', + action, + } + : undefined; + }, [callInProgress, shouldAllowCalls, t, joinCall, startCall]); + + return videoCallOption; +}; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts new file mode 100644 index 000000000000..44ce5ef6c2da --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/index.ts @@ -0,0 +1 @@ +export { useUserInfoActions } from './useUserInfoActions'; diff --git a/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts new file mode 100644 index 000000000000..f54586c0a18e --- /dev/null +++ b/apps/meteor/client/views/room/hooks/useUserInfoActions/useUserInfoActions.ts @@ -0,0 +1,60 @@ +import { IRoom, IUser } from '@rocket.chat/core-typings'; +import { useMemo } from 'react'; + +import { Action } from '../../../hooks/useActionSpread'; +import { useAudioCallAction } from './actions/useAudioCallAction'; +import { useBlockUserAction } from './actions/useBlockUserAction'; +import { useChangeLeaderAction } from './actions/useChangeLeaderAction'; +import { useChangeModeratorAction } from './actions/useChangeModeratorAction'; +import { useChangeOwnerAction } from './actions/useChangeOwnerAction'; +import { useDirectMessageAction } from './actions/useDirectMessageAction'; +import { useIgnoreUserAction } from './actions/useIgnoreUserAction'; +import { useMuteUserAction } from './actions/useMuteUserAction'; +import { useRemoveUserAction } from './actions/useRemoveUserAction'; +import { useVideoCallAction } from './actions/useVideoCallAction'; + +export const useUserInfoActions = ( + user: Pick, + rid: IRoom['_id'], + reload?: () => void, +): { + [key: string]: Action; +} => { + const audioCallOption = useAudioCallAction(rid); + const blockUserOption = useBlockUserAction(user, rid); + const changeLeaderOption = useChangeLeaderAction(user, rid); + const changeModeratorOption = useChangeModeratorAction(user, rid); + const changeOwnerOption = useChangeOwnerAction(user, rid); + const openDirectMessageOption = useDirectMessageAction(user, rid); + const ignoreUserOption = useIgnoreUserAction(user, rid); + const muteUserOption = useMuteUserAction(user, rid); + const removeUserOption = useRemoveUserAction(user, rid, reload); + const videoCallOption = useVideoCallAction(rid); + + return useMemo( + () => ({ + ...(openDirectMessageOption && { openDirectMessage: openDirectMessageOption }), + ...(videoCallOption && { video: videoCallOption }), + ...(audioCallOption && { audio: audioCallOption }), + ...(changeOwnerOption && { changeOwner: changeOwnerOption }), + ...(changeLeaderOption && { changeLeader: changeLeaderOption }), + ...(changeModeratorOption && { changeModerator: changeModeratorOption }), + ...(ignoreUserOption && { ignoreUser: ignoreUserOption }), + ...(muteUserOption && { muteUser: muteUserOption }), + ...(blockUserOption && { toggleBlock: blockUserOption }), + ...(removeUserOption && { removeUser: removeUserOption }), + }), + [ + audioCallOption, + changeLeaderOption, + changeModeratorOption, + changeOwnerOption, + ignoreUserOption, + muteUserOption, + openDirectMessageOption, + removeUserOption, + videoCallOption, + blockUserOption, + ], + ); +}; diff --git a/apps/meteor/client/views/room/lib/getRoomDirectives.ts b/apps/meteor/client/views/room/lib/getRoomDirectives.ts new file mode 100644 index 000000000000..2f5602f02d2b --- /dev/null +++ b/apps/meteor/client/views/room/lib/getRoomDirectives.ts @@ -0,0 +1,33 @@ +import { IRoom } from '@rocket.chat/core-typings'; + +import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; +import { roomCoordinator } from '../../../lib/rooms/roomCoordinator'; + +type getRoomDirectiesType = { + roomCanSetOwner: boolean; + roomCanSetLeader: boolean; + roomCanSetModerator: boolean; + roomCanIgnore: boolean; + roomCanBlock: boolean; + roomCanMute: boolean; + roomCanRemove: boolean; +}; + +export const getRoomDirectives = (room: IRoom): getRoomDirectiesType => { + const roomDirectives = room?.t && roomCoordinator.getRoomDirectives(room.t); + + const [roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove] = [ + ...((roomDirectives && [ + roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_OWNER), + roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_LEADER), + roomDirectives.allowMemberAction(room, RoomMemberActions.SET_AS_MODERATOR), + roomDirectives.allowMemberAction(room, RoomMemberActions.IGNORE), + roomDirectives.allowMemberAction(room, RoomMemberActions.BLOCK), + roomDirectives.allowMemberAction(room, RoomMemberActions.MUTE), + roomDirectives.allowMemberAction(room, RoomMemberActions.REMOVE_USER), + ]) ?? + []), + ]; + + return { roomCanSetOwner, roomCanSetLeader, roomCanSetModerator, roomCanIgnore, roomCanBlock, roomCanMute, roomCanRemove }; +}; diff --git a/apps/meteor/client/views/root/AppLayout.tsx b/apps/meteor/client/views/root/AppLayout.tsx index f883e61f1dac..137b542fcdb6 100644 --- a/apps/meteor/client/views/root/AppLayout.tsx +++ b/apps/meteor/client/views/root/AppLayout.tsx @@ -1,5 +1,5 @@ import React, { FC, Fragment, Suspense } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { appLayout } from '../../lib/appLayout'; import { blazePortals } from '../../lib/portals/blazePortals'; @@ -9,8 +9,8 @@ import { useTooltipHandling } from './useTooltipHandling'; const AppLayout: FC = () => { useTooltipHandling(); - const layout = useSubscription(appLayout); - const portals = useSubscription(blazePortals); + const layout = useSyncExternalStore(appLayout.subscribe, appLayout.getSnapshot); + const portals = useSyncExternalStore(blazePortals.subscribe, blazePortals.getSnapshot); return ( <> diff --git a/apps/meteor/client/views/root/PortalsWrapper.tsx b/apps/meteor/client/views/root/PortalsWrapper.tsx index 99c51a455a16..cc31f762e303 100644 --- a/apps/meteor/client/views/root/PortalsWrapper.tsx +++ b/apps/meteor/client/views/root/PortalsWrapper.tsx @@ -1,11 +1,11 @@ import React, { FC } from 'react'; -import { useSubscription } from 'use-subscription'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { portalsSubscription } from '../../lib/portals/portalsSubscription'; import PortalWrapper from './PortalWrapper'; const PortalsWrapper: FC = () => { - const portals = useSubscription(portalsSubscription); + const portals = useSyncExternalStore(portalsSubscription.subscribe, portalsSubscription.getSnapshot); return ( <> diff --git a/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx b/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx index c035fa4a7134..d131b403d925 100644 --- a/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx +++ b/apps/meteor/client/views/teams/CreateTeamModal/CreateTeamModal.tsx @@ -1,207 +1,13 @@ -import type { IUser } from '@rocket.chat/core-typings'; import { Box, Modal, ButtonGroup, Button, TextInput, Field, ToggleSwitch, FieldGroup } from '@rocket.chat/fuselage'; -import { useMutableCallback, useDebouncedCallback, useAutoFocus } from '@rocket.chat/fuselage-hooks'; -import { useSetting, usePermission, useMethod, useTranslation } from '@rocket.chat/ui-contexts'; -import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; +import { useTranslation } from '@rocket.chat/ui-contexts'; +import React, { memo, ReactElement } from 'react'; -import { useEndpointActionExperimental } from '../../../hooks/useEndpointActionExperimental'; -import { useForm } from '../../../hooks/useForm'; -import { goToRoomById } from '../../../lib/utils/goToRoomById'; +import UserAutoCompleteMultiple from '../../../components/UserAutoCompleteMultiple'; import TeamNameInput from './TeamNameInput'; -import UsersInput from './UsersInput'; +import { useCreateTeamModalState } from './useCreateTeamModalState'; -type CreateTeamModalState = { - name: any; - nameError: any; - onChangeName: any; - description: any; - onChangeDescription: any; - type: any; - onChangeType: any; - readOnly: any; - canChangeReadOnly: any; - onChangeReadOnly: any; - encrypted: any; - canChangeEncrypted: any; - onChangeEncrypted: any; - broadcast: any; - onChangeBroadcast: any; - members: any; - onChangeMembers: any; - hasUnsavedChanges: any; - isCreateButtonEnabled: any; - onCreate: any; -}; - -const useCreateTeamModalState = (onClose: () => void): CreateTeamModalState => { - const e2eEnabled = useSetting('E2E_Enable'); - const e2eEnabledForPrivateByDefault = useSetting('E2E_Enabled_Default_PrivateRooms'); - const namesValidation = useSetting('UTF8_Channel_Names_Validation'); - const allowSpecialNames = useSetting('UI_Allow_room_names_with_special_chars'); - - const { values, handlers, hasUnsavedChanges } = useForm({ - members: [], - name: '', - description: '', - type: true, - readOnly: false, - encrypted: e2eEnabledForPrivateByDefault ?? false, - broadcast: false, - }); - - const { name, description, type, readOnly, broadcast, encrypted, members } = values as { - name: string; - description: string; - type: boolean; - readOnly: boolean; - broadcast: boolean; - encrypted: boolean; - members: Exclude[]; - }; - - const { handleMembers, handleEncrypted, handleType, handleBroadcast, handleReadOnly } = handlers; - - const t = useTranslation(); - - const teamNameRegex = useMemo(() => { - if (allowSpecialNames) { - return null; - } - - return new RegExp(`^${namesValidation}$`); - }, [allowSpecialNames, namesValidation]); - - const [nameError, setNameError] = useState(); - - const teamNameExists = useMethod('roomNameExists'); - - const checkName = useDebouncedCallback( - async (name: string) => { - setNameError(undefined); - - if (!hasUnsavedChanges) { - return; - } - - if (!name || name.length === 0) { - setNameError(t('Field_required')); - return; - } - - if (teamNameRegex && !teamNameRegex.test(name)) { - setNameError(t('error-invalid-name')); - return; - } - - const isNotAvailable = await teamNameExists(name); - if (isNotAvailable) { - setNameError(t('Teams_Errors_team_name', { name })); - } - }, - 230, - [name], - ); - - useEffect(() => { - checkName(name); - }, [checkName, name]); - - const canChangeReadOnly = !broadcast; - - const canChangeEncrypted = type && !broadcast && e2eEnabled && !e2eEnabledForPrivateByDefault; - - const onChangeName = handlers.handleName; - - const onChangeDescription = handlers.handleDescription; - - const onChangeType = useMutableCallback((value) => { - handleEncrypted(!value); - return handleType(value); - }); - - const onChangeReadOnly = handlers.handleReadOnly; - - const onChangeEncrypted = handlers.handleEncrypted; - - const onChangeBroadcast = useCallback( - (value) => { - handleEncrypted(!value); - handleReadOnly(value); - return handleBroadcast(value); - }, - [handleBroadcast, handleEncrypted, handleReadOnly], - ); - - const onChangeMembers = useCallback( - (value, action) => { - if (!action) { - if (members.includes(value)) { - return; - } - return handleMembers([...members, value]); - } - handleMembers(members.filter((current) => current !== value)); - }, - [handleMembers, members], - ); - - const canSave = hasUnsavedChanges && !nameError; - const canCreateTeam = usePermission('create-team'); - const isCreateButtonEnabled = canSave && canCreateTeam; - - const createTeam = useEndpointActionExperimental('POST', '/v1/teams.create'); - - const onCreate = useCallback(async () => { - const params = { - name, - members, - type: type ? 1 : 0, - room: { - readOnly, - extraData: { - description, - broadcast, - encrypted, - }, - }, - }; - - const data = await createTeam(params); - - goToRoomById(data.team.roomId); - - onClose(); - }, [name, members, type, readOnly, description, broadcast, encrypted, createTeam, onClose]); - - return { - name, - nameError, - onChangeName, - description, - onChangeDescription, - type, - onChangeType, - readOnly, - canChangeReadOnly, - onChangeReadOnly, - encrypted, - canChangeEncrypted, - onChangeEncrypted, - broadcast, - onChangeBroadcast, - members, - onChangeMembers, - hasUnsavedChanges, - isCreateButtonEnabled, - onCreate, - }; -}; - -type CreateTeamModalProps = { - onClose: () => void; -}; - -const CreateTeamModal: FC = ({ onClose }) => { +const CreateTeamModal = ({ onClose }: { onClose: () => void }): ReactElement => { const { name, nameError, @@ -226,14 +32,13 @@ const CreateTeamModal: FC = ({ onClose }) => { } = useCreateTeamModalState(onClose); const t = useTranslation(); - const focusRef = useAutoFocus(); return ( {t('Teams_New_Title')} - + @@ -310,7 +115,7 @@ const CreateTeamModal: FC = ({ onClose }) => { ({t('optional')}) - + diff --git a/apps/meteor/client/views/teams/CreateTeamModal/UsersInput.tsx b/apps/meteor/client/views/teams/CreateTeamModal/UsersInput.tsx deleted file mode 100644 index 13126e2c6838..000000000000 --- a/apps/meteor/client/views/teams/CreateTeamModal/UsersInput.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { AutoComplete, Box, Option, Options, Chip, AutoCompleteProps } from '@rocket.chat/fuselage'; -import { useDebouncedValue } from '@rocket.chat/fuselage-hooks'; -import React, { FC, memo, useCallback, useMemo, useState } from 'react'; - -import UserAvatar from '../../../components/avatar/UserAvatar'; -import { useEndpointData } from '../../../hooks/useEndpointData'; - -type UsersInputProps = { - value: unknown[]; - onChange: (value: unknown, action: 'remove' | undefined) => void; -}; - -type AutocompleteData = [AutoCompleteProps['options'], { [key: string]: string | undefined }]; - -const useUsersAutoComplete = (term: string): AutocompleteData => { - const params = useMemo( - () => ({ - selector: JSON.stringify({ term }), - }), - [term], - ); - const { value: data } = useEndpointData('/v1/users.autocomplete', params); - - return useMemo(() => { - if (!data) { - return [[], {}]; - } - - const options = - data.items.map((user) => ({ - label: user.name ?? '', - value: user._id ?? '', - })) || []; - - const labelData = Object.fromEntries(data.items.map((user) => [user._id, user.username]) || []); - - return [options, labelData]; - }, [data]); -}; - -const UsersInput: FC = ({ onChange, ...props }) => { - const [filter, setFilter] = useState(''); - const [options, labelData] = useUsersAutoComplete(useDebouncedValue(filter, 1000)); - - const onClickSelected = useCallback( - (e) => { - e.stopPropagation(); - e.preventDefault(); - onChange(e.currentTarget.value, 'remove'); - }, - [onChange], - ); - - const renderSelected = useCallback>( - ({ value: selected }) => ( - <> - {selected?.map((value) => ( - - - - {labelData[value]} - - - ))} - - ), - [onClickSelected, props, labelData], - ); - - const renderItem = useCallback>( - ({ value, ...props }) => ( -