From bcacbb1ceec16f9d6c3ee10cd29d0f9af1156779 Mon Sep 17 00:00:00 2001 From: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Mon, 30 Sep 2024 17:50:14 -0300 Subject: [PATCH] feat: Voip for Team Collaboration (#33346) Co-authored-by: Aleksander Nicacio da Silva <6494543+aleksandernsilva@users.noreply.github.com> --- .changeset/gorgeous-houses-sneeze.md | 14 + apps/meteor/app/api/server/lib/users.ts | 30 +- apps/meteor/app/api/server/v1/im.ts | 19 +- .../server/constant/permissions.ts | 7 + .../lib/server/functions/getFullUserData.ts | 3 + apps/meteor/app/models/client/models/Users.ts | 3 +- apps/meteor/app/utils/client/lib/SDKClient.ts | 1 + apps/meteor/client/NavBarV2/NavBar.tsx | 14 + .../UserMenu/hooks/useUserMenu.tsx | 5 + .../UserMenu/hooks/useVoipItems.tsx | 67 + .../NavBarItemVoipDialer.tsx | 48 + .../NavBarV2/NavBarVoipToolbar/index.ts | 1 + .../components/UserInfo/UserInfoAction.tsx | 18 +- .../useStartCallRoomAction/index.ts | 1 + .../useStartCallRoomAction.tsx | 41 + .../useVideoConfMenuOptions.tsx} | 77 +- .../useVoipMenuOptions.tsx | 69 + apps/meteor/client/hooks/useUserInfoQuery.ts | 15 +- .../client/providers/MeteorProvider.tsx | 11 +- .../sidebar/header/hooks/useUserMenu.tsx | 5 + .../sidebar/header/hooks/useVoipItems.tsx | 67 + .../admin/users/AdminUserInfoActions.tsx | 4 +- .../admin/users/AdminUserInfoWithData.tsx | 4 +- .../views/admin/users/AdminUsersPage.tsx | 10 +- ...UserPageHeaderContentWithSeatsCap.spec.tsx | 24 + .../UserPageHeaderContentWithSeatsCap.tsx | 11 +- .../users/UsersTable/UsersTable.spec.tsx | 98 + .../admin/users/UsersTable/UsersTable.tsx | 26 +- .../admin/users/UsersTable/UsersTableRow.tsx | 37 +- .../admin/users/hooks/useFilteredUsers.ts | 8 +- .../users/hooks/useVoipExtensionAction.tsx | 37 + .../users/voip/AssignExtensionButton.tsx | 26 + .../users/voip/AssignExtensionModal.spec.tsx | 129 ++ .../admin/users/voip/AssignExtensionModal.tsx | 148 ++ .../users/voip/RemoveExtensionModal.spec.tsx | 54 + .../admin/users/voip/RemoveExtensionModal.tsx | 81 + .../views/room/UserCard/UserCardWithData.tsx | 16 +- .../contextualBar/RoomMembers/RoomMembers.tsx | 2 +- .../RoomMembers/RoomMembersActions.tsx | 17 +- .../RoomMembers/RoomMembersItem.tsx | 16 +- .../RoomMembers/RoomMembersRow.tsx | 3 +- .../UserInfo/UserInfoActions.tsx | 19 +- .../UserInfo/UserInfoWithData.tsx | 2 + ...eCallAction.tsx => useVideoCallAction.tsx} | 12 +- .../actions/useVoipCallAction.tsx | 43 + .../useUserInfoActions/useUserInfoActions.ts | 57 +- .../ee/app/api-enterprise/server/index.ts | 4 + .../api-enterprise/server/voip-freeswitch.ts | 150 ++ apps/meteor/ee/server/configuration/index.ts | 1 + apps/meteor/ee/server/configuration/voip.ts | 10 + .../local-services/voip-freeswitch/service.ts | 53 + apps/meteor/ee/server/settings/voip.ts | 49 + apps/meteor/ee/server/startup/services.ts | 3 + apps/meteor/jest.config.ts | 1 + apps/meteor/package.json | 3 + apps/meteor/server/models/raw/Users.js | 44 +- .../tests/e2e/channel-management.spec.ts | 3 +- .../page-objects/fragments/home-content.ts | 10 +- .../tests/e2e/video-conference-ring.spec.ts | 9 +- .../meteor/tests/e2e/video-conference.spec.ts | 12 +- apps/meteor/tests/mocks/client/meteor.ts | 21 +- .../tests/unit/server/lib/freeswitch.tests.ts | 40 + packages/core-services/src/index.ts | 3 + .../src/types/IVoipFreeSwitchService.ts | 8 + packages/core-typings/src/IUser.ts | 1 + .../src/voip/FreeSwitchExtension.ts | 11 + packages/core-typings/src/voip/index.ts | 1 + packages/freeswitch/.eslintrc.json | 4 + packages/freeswitch/jest.config.ts | 6 + packages/freeswitch/package.json | 30 + packages/freeswitch/src/FreeSwitchOptions.ts | 1 + packages/freeswitch/src/commands/getDomain.ts | 25 + .../src/commands/getExtensionDetails.ts | 30 + .../src/commands/getExtensionList.ts | 17 + .../src/commands/getUserPassword.ts | 31 + packages/freeswitch/src/commands/index.ts | 4 + packages/freeswitch/src/connect.ts | 82 + packages/freeswitch/src/getCommandResponse.ts | 12 + packages/freeswitch/src/index.ts | 1 + packages/freeswitch/src/logger.ts | 3 + packages/freeswitch/src/runCommand.ts | 30 + packages/freeswitch/src/utils/mapUserData.ts | 33 + .../freeswitch/src/utils/parseUserList.ts | 54 + .../freeswitch/src/utils/parseUserStatus.ts | 17 + packages/freeswitch/tests/mapUserData.test.ts | 37 + .../freeswitch/tests/parseUserList.test.ts | 214 +++ .../freeswitch/tests/parseUserStatus.test.ts | 14 + .../tests/utils/makeFreeSwitchResponse.ts | 5 + packages/freeswitch/tsconfig.json | 9 + packages/i18n/src/locales/en.i18n.json | 52 + packages/i18n/src/locales/pt-BR.i18n.json | 34 +- .../src/MockedAppRootBuilder.tsx | 9 +- .../model-typings/src/models/IUsersModel.ts | 4 + packages/rest-typings/src/index.ts | 3 + packages/rest-typings/src/v1/users.ts | 13 +- .../VoipFreeSwitchExtensionAssignProps.ts | 24 + .../VoipFreeSwitchExtensionGetDetailsProps.ts | 27 + .../VoipFreeSwitchExtensionGetInfoProps.ts | 22 + .../VoipFreeSwitchExtensionListProps.ts | 28 + .../src/v1/voip-freeswitch/index.ts | 29 + .../components/GenericMenu/GenericMenu.tsx | 13 +- packages/ui-voip/.eslintignore | 1 + packages/ui-voip/.eslintrc.json | 67 + packages/ui-voip/.storybook/main.js | 12 + packages/ui-voip/.storybook/preview.js | 26 + packages/ui-voip/jest.config.ts | 13 + packages/ui-voip/package.json | 78 + .../VoipActionButton.spec.tsx | 19 + .../VoipActionButton.stories.tsx | 22 + .../VoipActionButton/VoipActionButton.tsx | 27 + .../VoipActionButton.spec.tsx.snap | 61 + .../src/components/VoipActionButton/index.ts | 1 + .../VoipActions/VoipActions.spec.tsx | 20 + .../VoipActions/VoipActions.stories.tsx | 24 + .../components/VoipActions/VoipActions.tsx | 85 + .../__snapshots__/VoipActions.spec.tsx.snap | 239 +++ .../src/components/VoipActions/index.ts | 1 + .../VoipContactId/VoipContactId.spec.tsx | 65 + .../VoipContactId/VoipContactId.stories.tsx | 36 + .../VoipContactId/VoipContactId.tsx | 74 + .../__snapshots__/VoipContactId.spec.tsx.snap | 147 ++ .../src/components/VoipContactId/index.ts | 1 + .../VoipDialPad/VoipDialPad.spec.tsx | 75 + .../VoipDialPad/VoipDialPad.stories.tsx | 21 + .../components/VoipDialPad/VoipDialPad.tsx | 68 + .../components/VoipDialPadButton.tsx | 49 + .../components/VoipDialPadInput.tsx | 48 + .../src/components/VoipDialPad/index.ts | 1 + .../components/VoipPopup/VoipPopup.spec.tsx | 80 + .../VoipPopup/VoipPopup.stories.tsx | 91 + .../src/components/VoipPopup/VoipPopup.tsx | 38 + .../__snapshots__/VoipPopup.spec.tsx.snap | 1605 +++++++++++++++++ .../components/VoipPopupContainer.tsx | 49 + .../VoipPopup/components/VoipPopupContent.tsx | 10 + .../VoipPopup/components/VoipPopupFooter.tsx | 10 + .../components/VoipPopupHeader.spec.tsx | 42 + .../VoipPopup/components/VoipPopupHeader.tsx | 34 + .../components/VoipPopup/components/index.ts | 4 + .../ui-voip/src/components/VoipPopup/index.ts | 1 + .../VoipPopup/views/VoipDialerView.spec.tsx | 47 + .../VoipPopup/views/VoipDialerView.tsx | 49 + .../VoipPopup/views/VoipErrorView.spec.tsx | 74 + .../VoipPopup/views/VoipErrorView.tsx | 57 + .../VoipPopup/views/VoipIncomingView.spec.tsx | 38 + .../VoipPopup/views/VoipIncomingView.tsx | 36 + .../VoipPopup/views/VoipOngoingView.spec.tsx | 62 + .../VoipPopup/views/VoipOngoingView.tsx | 68 + .../VoipPopup/views/VoipOutgoingView.spec.tsx | 35 + .../VoipPopup/views/VoipOutgoingView.tsx | 36 + .../src/components/VoipPopup/views/index.ts | 5 + .../VoipPopupPortal/VoipPopupPortal.tsx | 15 + .../src/components/VoipPopupPortal/index.ts | 1 + .../VoipSettingsButton/VoipSettingsButton.tsx | 34 + .../hooks/useVoipDeviceSettings.spec.tsx | 46 + .../hooks/useVoipDeviceSettings.tsx | 75 + .../components/VoipSettingsButton/index.ts | 2 + .../components/VoipStatus/VoipStatus.spec.tsx | 32 + .../src/components/VoipStatus/VoipStatus.tsx | 28 + .../src/components/VoipStatus/index.ts | 2 + .../components/VoipTimer/VoipTimer.spec.tsx | 37 + .../src/components/VoipTimer/VoipTimer.tsx | 35 + .../ui-voip/src/components/VoipTimer/index.ts | 1 + .../VoipTransferModal.spec.tsx | 94 + .../VoipTransferModal/VoipTransferModal.tsx | 82 + .../src/components/VoipTransferModal/index.ts | 1 + packages/ui-voip/src/components/index.ts | 7 + packages/ui-voip/src/contexts/VoipContext.tsx | 44 + packages/ui-voip/src/definitions/IceServer.ts | 5 + .../ui-voip/src/definitions/VoipSession.ts | 69 + packages/ui-voip/src/definitions/index.ts | 2 + packages/ui-voip/src/hooks/index.ts | 6 + packages/ui-voip/src/hooks/useVoip.tsx | 23 + packages/ui-voip/src/hooks/useVoipAPI.tsx | 52 + packages/ui-voip/src/hooks/useVoipClient.tsx | 89 + .../ui-voip/src/hooks/useVoipContactId.tsx | 25 + packages/ui-voip/src/hooks/useVoipDialer.tsx | 13 + packages/ui-voip/src/hooks/useVoipEffect.tsx | 30 + packages/ui-voip/src/hooks/useVoipEvent.tsx | 28 + .../src/hooks/useVoipExtensionDetails.tsx | 20 + packages/ui-voip/src/hooks/useVoipSession.tsx | 6 + packages/ui-voip/src/hooks/useVoipSounds.ts | 26 + packages/ui-voip/src/hooks/useVoipState.tsx | 45 + .../src/hooks/useVoipTransferModal.tsx | 51 + .../ui-voip/src/hooks/useWebRtcServers.ts | 16 + packages/ui-voip/src/index.ts | 4 + packages/ui-voip/src/lib/LocalStream.ts | 106 ++ packages/ui-voip/src/lib/RemoteStream.ts | 75 + packages/ui-voip/src/lib/Stream.ts | 67 + packages/ui-voip/src/lib/VoipClient.ts | 868 +++++++++ .../ui-voip/src/providers/VoipProvider.tsx | 177 ++ packages/ui-voip/src/tests/mocks/index.ts | 75 + .../src/tests/utils/replaceReactAriaIds.ts | 22 + .../utils/parseStringToIceServers/index.ts | 1 + .../parseStringToIceServers.spec.ts | 50 + .../parseStringToIceServers.ts | 21 + .../ui-voip/src/utils/setPreciseInterval.ts | 25 + packages/ui-voip/tsconfig.build.json | 5 + packages/ui-voip/tsconfig.json | 8 + yarn.lock | 107 +- 199 files changed, 8996 insertions(+), 163 deletions(-) create mode 100644 .changeset/gorgeous-houses-sneeze.md create mode 100644 apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useVoipItems.tsx create mode 100644 apps/meteor/client/NavBarV2/NavBarVoipToolbar/NavBarItemVoipDialer.tsx create mode 100644 apps/meteor/client/NavBarV2/NavBarVoipToolbar/index.ts create mode 100644 apps/meteor/client/hooks/roomActions/useStartCallRoomAction/index.ts create mode 100644 apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useStartCallRoomAction.tsx rename apps/meteor/client/hooks/roomActions/{useStartCallRoomAction.ts => useStartCallRoomAction/useVideoConfMenuOptions.tsx} (53%) create mode 100644 apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVoipMenuOptions.tsx create mode 100644 apps/meteor/client/sidebar/header/hooks/useVoipItems.tsx create mode 100644 apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.spec.tsx create mode 100644 apps/meteor/client/views/admin/users/UsersTable/UsersTable.spec.tsx create mode 100644 apps/meteor/client/views/admin/users/hooks/useVoipExtensionAction.tsx create mode 100644 apps/meteor/client/views/admin/users/voip/AssignExtensionButton.tsx create mode 100644 apps/meteor/client/views/admin/users/voip/AssignExtensionModal.spec.tsx create mode 100644 apps/meteor/client/views/admin/users/voip/AssignExtensionModal.tsx create mode 100644 apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.spec.tsx create mode 100644 apps/meteor/client/views/admin/users/voip/RemoveExtensionModal.tsx rename apps/meteor/client/views/room/hooks/useUserInfoActions/actions/{useCallAction.tsx => useVideoCallAction.tsx} (85%) create mode 100644 apps/meteor/client/views/room/hooks/useUserInfoActions/actions/useVoipCallAction.tsx create mode 100644 apps/meteor/ee/app/api-enterprise/server/voip-freeswitch.ts create mode 100644 apps/meteor/ee/server/configuration/voip.ts create mode 100644 apps/meteor/ee/server/local-services/voip-freeswitch/service.ts create mode 100644 apps/meteor/ee/server/settings/voip.ts create mode 100644 apps/meteor/tests/unit/server/lib/freeswitch.tests.ts create mode 100644 packages/core-services/src/types/IVoipFreeSwitchService.ts create mode 100644 packages/core-typings/src/voip/FreeSwitchExtension.ts create mode 100644 packages/freeswitch/.eslintrc.json create mode 100644 packages/freeswitch/jest.config.ts create mode 100644 packages/freeswitch/package.json create mode 100644 packages/freeswitch/src/FreeSwitchOptions.ts create mode 100644 packages/freeswitch/src/commands/getDomain.ts create mode 100644 packages/freeswitch/src/commands/getExtensionDetails.ts create mode 100644 packages/freeswitch/src/commands/getExtensionList.ts create mode 100644 packages/freeswitch/src/commands/getUserPassword.ts create mode 100644 packages/freeswitch/src/commands/index.ts create mode 100644 packages/freeswitch/src/connect.ts create mode 100644 packages/freeswitch/src/getCommandResponse.ts create mode 100644 packages/freeswitch/src/index.ts create mode 100644 packages/freeswitch/src/logger.ts create mode 100644 packages/freeswitch/src/runCommand.ts create mode 100644 packages/freeswitch/src/utils/mapUserData.ts create mode 100644 packages/freeswitch/src/utils/parseUserList.ts create mode 100644 packages/freeswitch/src/utils/parseUserStatus.ts create mode 100644 packages/freeswitch/tests/mapUserData.test.ts create mode 100644 packages/freeswitch/tests/parseUserList.test.ts create mode 100644 packages/freeswitch/tests/parseUserStatus.test.ts create mode 100644 packages/freeswitch/tests/utils/makeFreeSwitchResponse.ts create mode 100644 packages/freeswitch/tsconfig.json create mode 100644 packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionAssignProps.ts create mode 100644 packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetDetailsProps.ts create mode 100644 packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionGetInfoProps.ts create mode 100644 packages/rest-typings/src/v1/voip-freeswitch/VoipFreeSwitchExtensionListProps.ts create mode 100644 packages/rest-typings/src/v1/voip-freeswitch/index.ts create mode 100644 packages/ui-voip/.eslintignore create mode 100644 packages/ui-voip/.eslintrc.json create mode 100644 packages/ui-voip/.storybook/main.js create mode 100644 packages/ui-voip/.storybook/preview.js create mode 100644 packages/ui-voip/jest.config.ts create mode 100644 packages/ui-voip/package.json create mode 100644 packages/ui-voip/src/components/VoipActionButton/VoipActionButton.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipActionButton/VoipActionButton.stories.tsx create mode 100644 packages/ui-voip/src/components/VoipActionButton/VoipActionButton.tsx create mode 100644 packages/ui-voip/src/components/VoipActionButton/__snapshots__/VoipActionButton.spec.tsx.snap create mode 100644 packages/ui-voip/src/components/VoipActionButton/index.ts create mode 100644 packages/ui-voip/src/components/VoipActions/VoipActions.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipActions/VoipActions.stories.tsx create mode 100644 packages/ui-voip/src/components/VoipActions/VoipActions.tsx create mode 100644 packages/ui-voip/src/components/VoipActions/__snapshots__/VoipActions.spec.tsx.snap create mode 100644 packages/ui-voip/src/components/VoipActions/index.ts create mode 100644 packages/ui-voip/src/components/VoipContactId/VoipContactId.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipContactId/VoipContactId.stories.tsx create mode 100644 packages/ui-voip/src/components/VoipContactId/VoipContactId.tsx create mode 100644 packages/ui-voip/src/components/VoipContactId/__snapshots__/VoipContactId.spec.tsx.snap create mode 100644 packages/ui-voip/src/components/VoipContactId/index.ts create mode 100644 packages/ui-voip/src/components/VoipDialPad/VoipDialPad.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipDialPad/VoipDialPad.stories.tsx create mode 100644 packages/ui-voip/src/components/VoipDialPad/VoipDialPad.tsx create mode 100644 packages/ui-voip/src/components/VoipDialPad/components/VoipDialPadButton.tsx create mode 100644 packages/ui-voip/src/components/VoipDialPad/components/VoipDialPadInput.tsx create mode 100644 packages/ui-voip/src/components/VoipDialPad/index.ts create mode 100644 packages/ui-voip/src/components/VoipPopup/VoipPopup.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/VoipPopup.stories.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/VoipPopup.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/__snapshots__/VoipPopup.spec.tsx.snap create mode 100644 packages/ui-voip/src/components/VoipPopup/components/VoipPopupContainer.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/components/VoipPopupContent.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/components/VoipPopupFooter.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/components/VoipPopupHeader.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/components/index.ts create mode 100644 packages/ui-voip/src/components/VoipPopup/index.ts create mode 100644 packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/views/VoipDialerView.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/views/VoipErrorView.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/views/VoipIncomingView.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/views/VoipIncomingView.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/views/VoipOngoingView.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/views/VoipOngoingView.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/views/VoipOutgoingView.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/views/VoipOutgoingView.tsx create mode 100644 packages/ui-voip/src/components/VoipPopup/views/index.ts create mode 100644 packages/ui-voip/src/components/VoipPopupPortal/VoipPopupPortal.tsx create mode 100644 packages/ui-voip/src/components/VoipPopupPortal/index.ts create mode 100644 packages/ui-voip/src/components/VoipSettingsButton/VoipSettingsButton.tsx create mode 100644 packages/ui-voip/src/components/VoipSettingsButton/hooks/useVoipDeviceSettings.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipSettingsButton/hooks/useVoipDeviceSettings.tsx create mode 100644 packages/ui-voip/src/components/VoipSettingsButton/index.ts create mode 100644 packages/ui-voip/src/components/VoipStatus/VoipStatus.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipStatus/VoipStatus.tsx create mode 100644 packages/ui-voip/src/components/VoipStatus/index.ts create mode 100644 packages/ui-voip/src/components/VoipTimer/VoipTimer.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipTimer/VoipTimer.tsx create mode 100644 packages/ui-voip/src/components/VoipTimer/index.ts create mode 100644 packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.spec.tsx create mode 100644 packages/ui-voip/src/components/VoipTransferModal/VoipTransferModal.tsx create mode 100644 packages/ui-voip/src/components/VoipTransferModal/index.ts create mode 100644 packages/ui-voip/src/components/index.ts create mode 100644 packages/ui-voip/src/contexts/VoipContext.tsx create mode 100644 packages/ui-voip/src/definitions/IceServer.ts create mode 100644 packages/ui-voip/src/definitions/VoipSession.ts create mode 100644 packages/ui-voip/src/definitions/index.ts create mode 100644 packages/ui-voip/src/hooks/index.ts create mode 100644 packages/ui-voip/src/hooks/useVoip.tsx create mode 100644 packages/ui-voip/src/hooks/useVoipAPI.tsx create mode 100644 packages/ui-voip/src/hooks/useVoipClient.tsx create mode 100644 packages/ui-voip/src/hooks/useVoipContactId.tsx create mode 100644 packages/ui-voip/src/hooks/useVoipDialer.tsx create mode 100644 packages/ui-voip/src/hooks/useVoipEffect.tsx create mode 100644 packages/ui-voip/src/hooks/useVoipEvent.tsx create mode 100644 packages/ui-voip/src/hooks/useVoipExtensionDetails.tsx create mode 100644 packages/ui-voip/src/hooks/useVoipSession.tsx create mode 100644 packages/ui-voip/src/hooks/useVoipSounds.ts create mode 100644 packages/ui-voip/src/hooks/useVoipState.tsx create mode 100644 packages/ui-voip/src/hooks/useVoipTransferModal.tsx create mode 100644 packages/ui-voip/src/hooks/useWebRtcServers.ts create mode 100644 packages/ui-voip/src/index.ts create mode 100644 packages/ui-voip/src/lib/LocalStream.ts create mode 100644 packages/ui-voip/src/lib/RemoteStream.ts create mode 100644 packages/ui-voip/src/lib/Stream.ts create mode 100644 packages/ui-voip/src/lib/VoipClient.ts create mode 100644 packages/ui-voip/src/providers/VoipProvider.tsx create mode 100644 packages/ui-voip/src/tests/mocks/index.ts create mode 100644 packages/ui-voip/src/tests/utils/replaceReactAriaIds.ts create mode 100644 packages/ui-voip/src/utils/parseStringToIceServers/index.ts create mode 100644 packages/ui-voip/src/utils/parseStringToIceServers/parseStringToIceServers.spec.ts create mode 100644 packages/ui-voip/src/utils/parseStringToIceServers/parseStringToIceServers.ts create mode 100644 packages/ui-voip/src/utils/setPreciseInterval.ts create mode 100644 packages/ui-voip/tsconfig.build.json create mode 100644 packages/ui-voip/tsconfig.json diff --git a/.changeset/gorgeous-houses-sneeze.md b/.changeset/gorgeous-houses-sneeze.md new file mode 100644 index 000000000000..b2846edb1687 --- /dev/null +++ b/.changeset/gorgeous-houses-sneeze.md @@ -0,0 +1,14 @@ +--- +'@rocket.chat/freeswitch': major +'@rocket.chat/mock-providers': patch +'@rocket.chat/core-services': patch +'@rocket.chat/model-typings': patch +'@rocket.chat/core-typings': patch +'@rocket.chat/rest-typings': patch +'@rocket.chat/ui-client': patch +'@rocket.chat/ui-voip': patch +'@rocket.chat/i18n': patch +'@rocket.chat/meteor': patch +--- + +Implements integration with FreeSwitch to enable VoIP calls for team collaboration workspaces diff --git a/apps/meteor/app/api/server/lib/users.ts b/apps/meteor/app/api/server/lib/users.ts index f8e3d528f163..0289f1fe5ff5 100644 --- a/apps/meteor/app/api/server/lib/users.ts +++ b/apps/meteor/app/api/server/lib/users.ts @@ -143,20 +143,6 @@ export async function findPaginatedUsersByStatus({ hasLoggedIn, type, }: FindPaginatedUsersByStatusProps) { - const projection = { - name: 1, - username: 1, - emails: 1, - roles: 1, - status: 1, - active: 1, - avatarETag: 1, - lastLogin: 1, - type: 1, - reason: 1, - federated: 1, - }; - const actualSort: Record = sort || { username: 1 }; if (sort?.status) { actualSort.active = sort.status; @@ -183,6 +169,22 @@ export async function findPaginatedUsersByStatus({ } const canSeeAllUserInfo = await hasPermissionAsync(uid, 'view-full-other-user-info'); + const canSeeExtension = canSeeAllUserInfo || (await hasPermissionAsync(uid, 'view-user-voip-extension')); + + const projection = { + name: 1, + username: 1, + emails: 1, + roles: 1, + status: 1, + active: 1, + avatarETag: 1, + lastLogin: 1, + type: 1, + reason: 1, + federated: 1, + ...(canSeeExtension ? { freeSwitchExtension: 1 } : {}), + }; match.$or = [ ...(canSeeAllUserInfo ? [{ 'emails.address': { $regex: escapeRegExp(searchTerm || ''), $options: 'i' } }] : []), diff --git a/apps/meteor/app/api/server/v1/im.ts b/apps/meteor/app/api/server/v1/im.ts index a640318a9cd0..5f12d7d7b751 100644 --- a/apps/meteor/app/api/server/v1/im.ts +++ b/apps/meteor/app/api/server/v1/im.ts @@ -17,7 +17,7 @@ import { Meteor } from 'meteor/meteor'; import { createDirectMessage } from '../../../../server/methods/createDirectMessage'; import { hideRoomMethod } from '../../../../server/methods/hideRoom'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; -import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { hasAtLeastOnePermissionAsync, hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { saveRoomSettings } from '../../../channel-settings/server/methods/saveRoomSettings'; import { getRoomByNameOrIdWithOptionToJoin } from '../../../lib/server/functions/getRoomByNameOrIdWithOptionToJoin'; import { settings } from '../../../settings/server'; @@ -327,8 +327,23 @@ API.v1.addRoute( ...(status && { status: { $in: status } }), }; + const canSeeExtension = await hasAtLeastOnePermissionAsync( + this.userId, + ['view-full-other-user-info', 'view-user-voip-extension'], + room._id, + ); + const options = { - projection: { _id: 1, username: 1, name: 1, status: 1, statusText: 1, utcOffset: 1, federated: 1 }, + projection: { + _id: 1, + username: 1, + name: 1, + status: 1, + statusText: 1, + utcOffset: 1, + federated: 1, + ...(canSeeExtension && { freeSwitchExtension: 1 }), + }, skip: offset, limit: count, sort: { diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index 46d40713bad1..f57943412fb4 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -208,6 +208,13 @@ export const permissions = [ // allows to receive a voip call { _id: 'inbound-voip-calls', roles: ['livechat-agent'] }, + // Allow managing team collab voip extensions + { _id: 'manage-voip-extensions', roles: ['admin'] }, + // Allow viewing the extension number of other users + { _id: 'view-user-voip-extension', roles: ['admin', 'user'] }, + // Allow viewing details of an extension + { _id: 'view-voip-extension-details', roles: ['admin', 'user'] }, + { _id: 'remove-livechat-department', roles: ['livechat-manager', 'admin'] }, { _id: 'manage-apps', roles: ['admin'] }, { _id: 'post-readonly', roles: ['admin', 'owner', 'moderator'] }, diff --git a/apps/meteor/app/lib/server/functions/getFullUserData.ts b/apps/meteor/app/lib/server/functions/getFullUserData.ts index 0703b24d9210..f66f8ecb49c6 100644 --- a/apps/meteor/app/lib/server/functions/getFullUserData.ts +++ b/apps/meteor/app/lib/server/functions/getFullUserData.ts @@ -35,6 +35,7 @@ const fullFields = { requirePasswordChangeReason: 1, roles: 1, importIds: 1, + freeSwitchExtension: 1, } as const; let publicCustomFields: Record = {}; @@ -85,6 +86,7 @@ export async function getFullUserDataByIdOrUsernameOrImportId( (searchType === 'username' && searchValue === caller.username) || (searchType === 'importId' && caller.importIds?.includes(searchValue)); const canViewAllInfo = !!myself || (await hasPermissionAsync(userId, 'view-full-other-user-info')); + const canViewExtension = !!myself || (await hasPermissionAsync(userId, 'view-user-voip-extension')); // Only search for importId if the user has permission to view the import id if (searchType === 'importId' && !canViewAllInfo) { @@ -96,6 +98,7 @@ export async function getFullUserDataByIdOrUsernameOrImportId( const options = { projection: { ...fields, + ...(canViewExtension && { freeSwitchExtension: 1 }), ...(myself && { services: 1 }), }, }; diff --git a/apps/meteor/app/models/client/models/Users.ts b/apps/meteor/app/models/client/models/Users.ts index e2d8c7856752..3fd1016e528b 100644 --- a/apps/meteor/app/models/client/models/Users.ts +++ b/apps/meteor/app/models/client/models/Users.ts @@ -1,4 +1,5 @@ import type { IRole, IUser } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; import { Mongo } from 'meteor/mongo'; class UsersCollection extends Mongo.Collection { @@ -39,4 +40,4 @@ Object.assign(Meteor.users, { }); /** @deprecated new code refer to Minimongo collections like this one; prefer fetching data from the REST API, listening to changes via streamer events, and storing the state in a Tanstack Query */ -export const Users = Meteor.users as UsersCollection; +export const Users = Meteor.users as unknown as UsersCollection; diff --git a/apps/meteor/app/utils/client/lib/SDKClient.ts b/apps/meteor/app/utils/client/lib/SDKClient.ts index 3c7e43c85f7c..5ca4e112ad69 100644 --- a/apps/meteor/app/utils/client/lib/SDKClient.ts +++ b/apps/meteor/app/utils/client/lib/SDKClient.ts @@ -1,6 +1,7 @@ import type { RestClientInterface } from '@rocket.chat/api-client'; import type { SDK, ClientStream, StreamKeys, StreamNames, StreamerCallbackArgs, ServerMethods } from '@rocket.chat/ddp-client'; import { Emitter } from '@rocket.chat/emitter'; +import { Accounts } from 'meteor/accounts-base'; import { DDPCommon } from 'meteor/ddp-common'; import { Meteor } from 'meteor/meteor'; diff --git a/apps/meteor/client/NavBarV2/NavBar.tsx b/apps/meteor/client/NavBarV2/NavBar.tsx index 908e729c956e..7e61d53e5eff 100644 --- a/apps/meteor/client/NavBarV2/NavBar.tsx +++ b/apps/meteor/client/NavBarV2/NavBar.tsx @@ -1,6 +1,7 @@ import { useToolbar } from '@react-aria/toolbar'; import { NavBar as NavBarComponent, NavBarSection, NavBarGroup, NavBarDivider } from '@rocket.chat/fuselage'; import { usePermission, useTranslation, useUser } from '@rocket.chat/ui-contexts'; +import { useVoipState } from '@rocket.chat/ui-voip'; import React, { useRef } from 'react'; import { useIsCallEnabled, useIsCallReady } from '../contexts/CallContext'; @@ -16,6 +17,7 @@ import { } from './NavBarOmnichannelToolbar'; import { NavBarItemMarketPlaceMenu, NavBarItemAuditMenu, NavBarItemDirectoryPage, NavBarItemHomePage } from './NavBarPagesToolbar'; import { NavBarItemLoginPage, NavBarItemAdministrationMenu, UserMenu } from './NavBarSettingsToolbar'; +import { NavBarItemVoipDialer } from './NavBarVoipToolbar'; const NavBar = () => { const t = useTranslation(); @@ -31,6 +33,7 @@ const NavBar = () => { const showOmnichannelQueueLink = useOmnichannelShowQueueLink(); const isCallEnabled = useIsCallEnabled(); const isCallReady = useIsCallReady(); + const { isEnabled: showVoip } = useVoipState(); const pagesToolbarRef = useRef(null); const { toolbarProps: pagesToolbarProps } = useToolbar({ 'aria-label': t('Pages') }, pagesToolbarRef); @@ -38,6 +41,9 @@ const NavBar = () => { const omnichannelToolbarRef = useRef(null); const { toolbarProps: omnichannelToolbarProps } = useToolbar({ 'aria-label': t('Omnichannel') }, omnichannelToolbarRef); + const voipToolbarRef = useRef(null); + const { toolbarProps: voipToolbarProps } = useToolbar({ 'aria-label': t('Voice_Call') }, voipToolbarRef); + return ( @@ -59,6 +65,14 @@ const NavBar = () => { )} + {showVoip && ( + <> + + + + + + )} diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx index 85a481f3e257..fce9c3d14fd4 100644 --- a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useUserMenu.tsx @@ -7,12 +7,14 @@ import React from 'react'; import UserMenuHeader from '../UserMenuHeader'; import { useAccountItems } from './useAccountItems'; import { useStatusItems } from './useStatusItems'; +import { useVoipItems } from './useVoipItems'; export const useUserMenu = (user: IUser) => { const t = useTranslation(); const statusItems = useStatusItems(); const accountItems = useAccountItems(); + const voipItems = useVoipItems(); const logout = useLogout(); const handleLogout = useEffectEvent(() => { @@ -35,6 +37,9 @@ export const useUserMenu = (user: IUser) => { title: t('Status'), items: statusItems, }, + { + items: voipItems, + }, { title: t('Account'), items: accountItems, diff --git a/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useVoipItems.tsx b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useVoipItems.tsx new file mode 100644 index 000000000000..b3e4cbf22d52 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarSettingsToolbar/UserMenu/hooks/useVoipItems.tsx @@ -0,0 +1,67 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useVoipAPI, useVoipState } from '@rocket.chat/ui-voip'; +import { useMutation } from '@tanstack/react-query'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const useVoipItems = (): GenericMenuItemProps[] => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const { clientError, isEnabled, isReady, isRegistered } = useVoipState(); + const { register, unregister } = useVoipAPI(); + + const toggleVoip = useMutation({ + mutationFn: async () => { + if (!isRegistered) { + await register(); + return true; + } + + await unregister(); + return false; + }, + onSuccess: (isEnabled: boolean) => { + dispatchToastMessage({ + type: 'success', + message: isEnabled ? t('Voice_calling_enabled') : t('Voice_calling_disabled'), + }); + }, + }); + + const tooltip = useMemo(() => { + if (clientError) { + return t(clientError.message); + } + + if (!isReady || toggleVoip.isLoading) { + return t('Loading'); + } + + return ''; + }, [clientError, isReady, toggleVoip.isLoading, t]); + + return useMemo(() => { + if (!isEnabled) { + return []; + } + + return [ + { + id: 'toggle-voip', + icon: isRegistered ? 'phone-disabled' : 'phone', + disabled: !isReady || toggleVoip.isLoading, + onClick: () => toggleVoip.mutate(), + content: ( + + {isRegistered ? t('Disable_voice_calling') : t('Enable_voice_calling')} + + ), + }, + ]; + }, [isEnabled, isRegistered, isReady, tooltip, t, toggleVoip]); +}; + +export default useVoipItems; diff --git a/apps/meteor/client/NavBarV2/NavBarVoipToolbar/NavBarItemVoipDialer.tsx b/apps/meteor/client/NavBarV2/NavBarVoipToolbar/NavBarItemVoipDialer.tsx new file mode 100644 index 000000000000..bdc62c41b1da --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarVoipToolbar/NavBarItemVoipDialer.tsx @@ -0,0 +1,48 @@ +import { NavBarItem } from '@rocket.chat/fuselage'; +import { useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useLayout } from '@rocket.chat/ui-contexts'; +import { useVoipDialer, useVoipState } from '@rocket.chat/ui-voip'; +import type { HTMLAttributes } from 'react'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type NavBarItemVoipDialerProps = Omit, 'is'> & { + primary?: boolean; +}; + +const NavBarItemVoipDialer = (props: NavBarItemVoipDialerProps) => { + const { t } = useTranslation(); + const { sidebar } = useLayout(); + const { clientError, isEnabled, isReady, isRegistered } = useVoipState(); + const { open: isDialerOpen, openDialer, closeDialer } = useVoipDialer(); + + const handleToggleDialer = useEffectEvent(() => { + sidebar.toggle(); + isDialerOpen ? closeDialer() : openDialer(); + }); + + const title = useMemo(() => { + if (!isReady && !clientError) { + return t('Loading'); + } + + if (!isRegistered || clientError) { + return t('Voice_calling_disabled'); + } + + return t('New_Call'); + }, [clientError, isReady, isRegistered, t]); + + return isEnabled ? ( + + ) : null; +}; + +export default NavBarItemVoipDialer; diff --git a/apps/meteor/client/NavBarV2/NavBarVoipToolbar/index.ts b/apps/meteor/client/NavBarV2/NavBarVoipToolbar/index.ts new file mode 100644 index 000000000000..7f6d317af229 --- /dev/null +++ b/apps/meteor/client/NavBarV2/NavBarVoipToolbar/index.ts @@ -0,0 +1 @@ +export { default as NavBarItemVoipDialer } from './NavBarItemVoipDialer'; diff --git a/apps/meteor/client/components/UserInfo/UserInfoAction.tsx b/apps/meteor/client/components/UserInfo/UserInfoAction.tsx index 97c64ecbede1..f58d0fb07482 100644 --- a/apps/meteor/client/components/UserInfo/UserInfoAction.tsx +++ b/apps/meteor/client/components/UserInfo/UserInfoAction.tsx @@ -1,4 +1,4 @@ -import { Button } from '@rocket.chat/fuselage'; +import { Button, IconButton } from '@rocket.chat/fuselage'; import type { Keys as IconName } from '@rocket.chat/icons'; import type { ReactElement, ComponentProps } from 'react'; import React from 'react'; @@ -7,10 +7,16 @@ type UserInfoActionProps = { icon: IconName; } & ComponentProps; -const UserInfoAction = ({ icon, label, ...props }: UserInfoActionProps): ReactElement => ( - -); +const UserInfoAction = ({ icon, label, title, ...props }: UserInfoActionProps): ReactElement => { + if (!label && icon && title) { + return ; + } + + return ( + + ); +}; export default UserInfoAction; diff --git a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/index.ts b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/index.ts new file mode 100644 index 000000000000..d01e5a6a5dff --- /dev/null +++ b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/index.ts @@ -0,0 +1 @@ +export * from './useStartCallRoomAction'; diff --git a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useStartCallRoomAction.tsx b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useStartCallRoomAction.tsx new file mode 100644 index 000000000000..ee3117d664d1 --- /dev/null +++ b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useStartCallRoomAction.tsx @@ -0,0 +1,41 @@ +import { GenericMenu } from '@rocket.chat/ui-client'; +import React, { useMemo } from 'react'; + +import HeaderToolbarAction from '../../../components/Header/HeaderToolbarAction'; +import type { RoomToolboxActionConfig } from '../../../views/room/contexts/RoomToolboxContext'; +import useVideoConfMenuOptions from './useVideoConfMenuOptions'; +import useVoipMenuOptions from './useVoipMenuOptions'; + +export const useStartCallRoomAction = () => { + const voipCall = useVideoConfMenuOptions(); + const videoCall = useVoipMenuOptions(); + + return useMemo((): RoomToolboxActionConfig | undefined => { + if (!videoCall.allowed && !voipCall.allowed) { + return undefined; + } + + return { + id: 'start-call', + title: 'Call', + icon: 'phone', + groups: [...videoCall.groups, ...voipCall.groups], + disabled: videoCall.disabled && voipCall.disabled, + full: true, + order: Math.max(voipCall.order, videoCall.order), + featured: true, + renderToolboxItem: ({ id, icon, title, disabled, className }) => ( + } + key={id} + title={title} + disabled={disabled} + items={[...voipCall.items, ...videoCall.items]} + className={className} + placement='bottom-start' + icon={icon} + /> + ), + }; + }, [videoCall, voipCall]); +}; diff --git a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVideoConfMenuOptions.tsx similarity index 53% rename from apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts rename to apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVideoConfMenuOptions.tsx index 8d1fa251c051..13b92e7f44a5 100644 --- a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction.ts +++ b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVideoConfMenuOptions.tsx @@ -1,17 +1,21 @@ -import { isRoomFederated } from '@rocket.chat/core-typings'; -import { useStableArray, useMutableCallback } from '@rocket.chat/fuselage-hooks'; -import { useSetting, useUser, usePermission } from '@rocket.chat/ui-contexts'; -import { useMemo } from 'react'; +import { isOmnichannelRoom, isRoomFederated } from '@rocket.chat/core-typings'; +import { Box } from '@rocket.chat/fuselage'; +import { useEffectEvent, useStableArray } from '@rocket.chat/fuselage-hooks'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { usePermission, useSetting, useUser } from '@rocket.chat/ui-contexts'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useVideoConfDispatchOutgoing, useVideoConfIsCalling, useVideoConfIsRinging } from '../../contexts/VideoConfContext'; -import { VideoConfManager } from '../../lib/VideoConfManager'; -import { useRoom } from '../../views/room/contexts/RoomContext'; -import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext'; -import { useVideoConfWarning } from '../../views/room/contextualBar/VideoConference/hooks/useVideoConfWarning'; +import { useVideoConfDispatchOutgoing, useVideoConfIsCalling, useVideoConfIsRinging } from '../../../contexts/VideoConfContext'; +import { VideoConfManager } from '../../../lib/VideoConfManager'; +import { useRoom } from '../../../views/room/contexts/RoomContext'; +import type { RoomToolboxActionConfig } from '../../../views/room/contexts/RoomToolboxContext'; +import { useVideoConfWarning } from '../../../views/room/contextualBar/VideoConference/hooks/useVideoConfWarning'; -export const useStartCallRoomAction = () => { +const useVideoConfMenuOptions = () => { + const { t } = useTranslation(); const room = useRoom(); + const user = useUser(); const federated = isRoomFederated(room); const ownUser = room.uids?.length === 1 ?? false; @@ -24,8 +28,6 @@ export const useStartCallRoomAction = () => { const isCalling = useVideoConfIsCalling(); const isRinging = useVideoConfIsRinging(); - const { t } = useTranslation(); - const enabledForDMs = useSetting('VideoConf_Enable_DMs', true); const enabledForChannels = useSetting('VideoConf_Enable_Channels', true); const enabledForTeams = useSetting('VideoConf_Enable_Teams', true); @@ -43,13 +45,13 @@ export const useStartCallRoomAction = () => { ].filter((group): group is RoomToolboxActionConfig['groups'][number] => !!group), ); - const enabled = groups.length > 0; - - const user = useUser(); - - const allowed = enabled && permittedToCallManagement && (!user?.username || !room.muted?.includes(user.username)) && !ownUser; + const visible = groups.length > 0; + const allowed = visible && permittedToCallManagement && (!user?.username || !room.muted?.includes(user.username)) && !ownUser; + const disabled = federated || (!!room.ro && !permittedToPostReadonly); + const tooltip = disabled ? t('core.Video_Call_unavailable_for_this_type_of_room') : ''; + const order = isOmnichannelRoom(room) ? -1 : 4; - const handleOpenVideoConf = useMutableCallback(async () => { + const handleOpenVideoConf = useEffectEvent(async () => { if (isCalling || isRinging) { return; } @@ -62,26 +64,29 @@ export const useStartCallRoomAction = () => { } }); - const disabled = federated || (!!room.ro && !permittedToPostReadonly); - - return useMemo((): RoomToolboxActionConfig | undefined => { - if (!allowed) { - return undefined; - } + return useMemo(() => { + const items: GenericMenuItemProps[] = [ + { + id: 'start-video-call', + icon: 'video', + disabled, + onClick: handleOpenVideoConf, + content: ( + + {t('Video_call')} + + ), + }, + ]; return { - id: 'start-call', + items, + disabled, + allowed, + order, groups, - title: 'Call', - icon: 'phone', - action: () => void handleOpenVideoConf(), - ...(disabled && { - tooltip: t('core.Video_Call_unavailable_for_this_type_of_room'), - disabled: true, - }), - full: true, - order: 4, - featured: true, }; - }, [allowed, disabled, groups, handleOpenVideoConf, t]); + }, [allowed, disabled, groups, handleOpenVideoConf, order, t, tooltip]); }; + +export default useVideoConfMenuOptions; diff --git a/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVoipMenuOptions.tsx b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVoipMenuOptions.tsx new file mode 100644 index 000000000000..ca62f372f4ca --- /dev/null +++ b/apps/meteor/client/hooks/roomActions/useStartCallRoomAction/useVoipMenuOptions.tsx @@ -0,0 +1,69 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useUserId } from '@rocket.chat/ui-contexts'; +import { useVoipAPI, useVoipState } from '@rocket.chat/ui-voip'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useMediaPermissions } from '../../../views/room/composer/messageBox/hooks/useMediaPermissions'; +import { useRoom } from '../../../views/room/contexts/RoomContext'; +import { useUserInfoQuery } from '../../useUserInfoQuery'; + +const useVoipMenuOptions = () => { + const { t } = useTranslation(); + const { uids = [] } = useRoom(); + const ownUserId = useUserId(); + + const [isMicPermissionDenied] = useMediaPermissions('microphone'); + + const { isEnabled, isRegistered, isInCall } = useVoipState(); + const { makeCall } = useVoipAPI(); + + const members = useMemo(() => uids.filter((uid) => uid !== ownUserId), [uids, ownUserId]); + const remoteUserId = members[0]; + + const { data: { user: remoteUser } = {}, isLoading } = useUserInfoQuery({ userId: remoteUserId }, { enabled: Boolean(remoteUserId) }); + + const isRemoteRegistered = !!remoteUser?.freeSwitchExtension; + const isDM = members.length === 1; + + const disabled = isMicPermissionDenied || !isDM || !isRemoteRegistered || !isRegistered || isInCall || isLoading; + + const title = useMemo(() => { + if (isMicPermissionDenied) { + return t('Microphone_access_not_allowed'); + } + + if (isInCall) { + return t('Unable_to_make_calls_while_another_is_ongoing'); + } + + return disabled ? t('Voice_calling_disabled') : ''; + }, [disabled, isInCall, isMicPermissionDenied, t]); + + return useMemo(() => { + const items: GenericMenuItemProps[] = [ + { + id: 'start-voip-call', + icon: 'phone', + disabled, + onClick: () => makeCall(remoteUser?.freeSwitchExtension as string), + content: ( + + {t('Voice_call')} + + ), + }, + ]; + + return { + items: isEnabled ? items : [], + groups: ['direct'] as const, + disabled, + allowed: isEnabled, + order: 4, + }; + }, [disabled, title, t, isEnabled, makeCall, remoteUser?.freeSwitchExtension]); +}; + +export default useVoipMenuOptions; diff --git a/apps/meteor/client/hooks/useUserInfoQuery.ts b/apps/meteor/client/hooks/useUserInfoQuery.ts index 4fac5212b803..fdbe793d60e3 100644 --- a/apps/meteor/client/hooks/useUserInfoQuery.ts +++ b/apps/meteor/client/hooks/useUserInfoQuery.ts @@ -1,13 +1,14 @@ import type { UsersInfoParamsGet } from '@rocket.chat/rest-typings'; import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -// a hook using tanstack useQuery and useEndpoint that fetches user information from the `users.info` endpoint -export const useUserInfoQuery = (params: UsersInfoParamsGet) => { - const getUserInfo = useEndpoint('GET', '/v1/users.info'); - const result = useQuery(['users.info', params], () => getUserInfo({ ...params }), { - keepPreviousData: true, - }); +type UserInfoQueryOptions = { + enabled?: boolean; + keepPreviousData?: boolean; +}; - return result; +// a hook using tanstack useQuery and useEndpoint that fetches user information from the `users.info` endpoint +export const useUserInfoQuery = (params: UsersInfoParamsGet, options: UserInfoQueryOptions = { keepPreviousData: true }) => { + const getUserInfo = useEndpoint('GET', '/v1/users.info'); + return useQuery(['users.info', params], () => getUserInfo({ ...params }), options); }; diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index 4817cee83317..ad5df9503833 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -1,3 +1,4 @@ +import { VoipProvider } from '@rocket.chat/ui-voip'; import type { ReactNode } from 'react'; import React from 'react'; @@ -6,7 +7,7 @@ import ActionManagerProvider from './ActionManagerProvider'; import AuthenticationProvider from './AuthenticationProvider/AuthenticationProvider'; import AuthorizationProvider from './AuthorizationProvider'; import AvatarUrlProvider from './AvatarUrlProvider'; -import { CallProvider } from './CallProvider'; +import { CallProvider as OmnichannelCallProvider } from './CallProvider'; import ConnectionStatusProvider from './ConnectionStatusProvider'; import CustomSoundProvider from './CustomSoundProvider'; import { DeviceProvider } from './DeviceProvider/DeviceProvider'; @@ -51,9 +52,11 @@ const MeteorProvider = ({ children }: MeteorProviderProps) => ( - - {children} - + + + {children} + + diff --git a/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx b/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx index c0c6f94a4ed8..e9ad8cc73836 100644 --- a/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx +++ b/apps/meteor/client/sidebar/header/hooks/useUserMenu.tsx @@ -7,12 +7,14 @@ import React from 'react'; import UserMenuHeader from '../UserMenuHeader'; import { useAccountItems } from './useAccountItems'; import { useStatusItems } from './useStatusItems'; +import useVoipItems from './useVoipItems'; export const useUserMenu = (user: IUser) => { const t = useTranslation(); const statusItems = useStatusItems(); const accountItems = useAccountItems(); + const voipItems = useVoipItems(); const logout = useLogout(); const handleLogout = useMutableCallback(() => { @@ -35,6 +37,9 @@ export const useUserMenu = (user: IUser) => { title: t('Status'), items: statusItems, }, + { + items: voipItems, + }, { title: t('Account'), items: accountItems, diff --git a/apps/meteor/client/sidebar/header/hooks/useVoipItems.tsx b/apps/meteor/client/sidebar/header/hooks/useVoipItems.tsx new file mode 100644 index 000000000000..d7cbf2428c32 --- /dev/null +++ b/apps/meteor/client/sidebar/header/hooks/useVoipItems.tsx @@ -0,0 +1,67 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { GenericMenuItemProps } from '@rocket.chat/ui-client'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useVoipAPI, useVoipState } from '@rocket.chat/ui-voip'; +import { useMutation } from '@tanstack/react-query'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const useVoipItems = (): GenericMenuItemProps[] => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const { clientError, isEnabled, isReady, isRegistered } = useVoipState(); + const { register, unregister } = useVoipAPI(); + + const toggleVoip = useMutation({ + mutationFn: async () => { + if (!isRegistered) { + await register(); + return true; + } + + await unregister(); + return false; + }, + onSuccess: (isEnabled: boolean) => { + dispatchToastMessage({ + type: 'success', + message: isEnabled ? t('Voice_calling_enabled') : t('Voice_calling_disabled'), + }); + }, + }); + + const tooltip = useMemo(() => { + if (clientError) { + return t(clientError.message); + } + + if (!isReady || toggleVoip.isLoading) { + return t('Loading'); + } + + return ''; + }, [clientError, isReady, toggleVoip.isLoading, t]); + + return useMemo(() => { + if (!isEnabled) { + return []; + } + + return [ + { + id: 'toggle-voip', + icon: isRegistered ? 'phone-disabled' : 'phone', + disabled: !isReady || toggleVoip.isLoading, + onClick: () => toggleVoip.mutate(), + content: ( + + {isRegistered ? t('Disable_voice_calling') : t('Enable_voice_calling')} + + ), + }, + ]; + }, [isEnabled, isRegistered, isReady, tooltip, t, toggleVoip]); +}; + +export default useVoipItems; diff --git a/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx b/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx index 800222282054..31ff9a96f842 100644 --- a/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserInfoActions.tsx @@ -6,7 +6,7 @@ import React, { useCallback, useMemo } from 'react'; import { UserInfoAction } from '../../../components/UserInfo'; import { useActionSpread } from '../../hooks/useActionSpread'; -import type { AdminUserTab } from './AdminUsersPage'; +import type { AdminUsersTab } from './AdminUsersPage'; import { useChangeAdminStatusAction } from './hooks/useChangeAdminStatusAction'; import { useChangeUserStatusAction } from './hooks/useChangeUserStatusAction'; import { useDeleteUserAction } from './hooks/useDeleteUserAction'; @@ -19,7 +19,7 @@ type AdminUserInfoActionsProps = { isFederatedUser: IUser['federated']; isActive: boolean; isAdmin: boolean; - tab: AdminUserTab; + tab: AdminUsersTab; onChange: () => void; onReload: () => void; }; diff --git a/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx b/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx index 2318e5ae1dc1..59d91ce5ada6 100644 --- a/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx +++ b/apps/meteor/client/views/admin/users/AdminUserInfoWithData.tsx @@ -14,12 +14,12 @@ import { UserInfo } from '../../../components/UserInfo'; import { UserStatus } from '../../../components/UserStatus'; import { getUserEmailVerified } from '../../../lib/utils/getUserEmailVerified'; import AdminUserInfoActions from './AdminUserInfoActions'; -import type { AdminUserTab } from './AdminUsersPage'; +import type { AdminUsersTab } from './AdminUsersPage'; type AdminUserInfoWithDataProps = { uid: IUser['_id']; onReload: () => void; - tab: AdminUserTab; + tab: AdminUsersTab; }; const AdminUserInfoWithData = ({ uid, onReload, tab }: AdminUserInfoWithDataProps): ReactElement => { diff --git a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx index 56641f8959d0..78950c0fe22a 100644 --- a/apps/meteor/client/views/admin/users/AdminUsersPage.tsx +++ b/apps/meteor/client/views/admin/users/AdminUsersPage.tsx @@ -39,9 +39,9 @@ export type UsersFilters = { roles: OptionProp[]; }; -export type AdminUserTab = 'all' | 'active' | 'deactivated' | 'pending'; +export type AdminUsersTab = 'all' | 'active' | 'deactivated' | 'pending'; -export type UsersTableSortingOptions = 'name' | 'username' | 'emails.address' | 'status' | 'active'; +export type UsersTableSortingOption = 'name' | 'username' | 'emails.address' | 'status' | 'active' | 'freeSwitchExtension'; const AdminUsersPage = (): ReactElement => { const t = useTranslation(); @@ -65,9 +65,9 @@ const AdminUsersPage = (): ReactElement => { const { data, error } = useQuery(['roles'], async () => getRoles()); const paginationData = usePagination(); - const sortData = useSort('name'); + const sortData = useSort('name'); - const [tab, setTab] = useState('all'); + const [tab, setTab] = useState('all'); const [userFilters, setUserFilters] = useState({ text: '', roles: [] }); const searchTerm = useDebouncedValue(userFilters.text, 500); @@ -89,7 +89,7 @@ const AdminUsersPage = (): ReactElement => { filteredUsersQueryResult?.refetch(); }; - const handleTabChange = (tab: AdminUserTab) => { + const handleTabChange = (tab: AdminUsersTab) => { setTab(tab); paginationData.setCurrent(0); diff --git a/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.spec.tsx b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.spec.tsx new file mode 100644 index 000000000000..b11ca46ce40d --- /dev/null +++ b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.spec.tsx @@ -0,0 +1,24 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import '@testing-library/jest-dom'; + +import UserPageHeaderContent from './UserPageHeaderContentWithSeatsCap'; + +it('should render "Associate Extension" button when VoIP_TeamCollab_Enabled setting is enabled', async () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot().withJohnDoe().withSetting('VoIP_TeamCollab_Enabled', true).build(), + }); + + expect(screen.getByRole('button', { name: 'Assign_extension' })).toBeEnabled(); +}); + +it('should not render "Associate Extension" button when VoIP_TeamCollab_Enabled setting is disabled', async () => { + render(, { + legacyRoot: true, + wrapper: mockAppRoot().withJohnDoe().withSetting('VoIP_TeamCollab_Enabled', false).build(), + }); + + expect(screen.queryByRole('button', { name: 'Assign_extension' })).not.toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx index 3000b4f51a5a..a023f5229816 100644 --- a/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx +++ b/apps/meteor/client/views/admin/users/UserPageHeaderContentWithSeatsCap.tsx @@ -1,11 +1,12 @@ import { Button, ButtonGroup, Margins } from '@rocket.chat/fuselage'; -import { useTranslation, useRouter } from '@rocket.chat/ui-contexts'; +import { useSetModal, useTranslation, useRouter, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React from 'react'; import { useExternalLink } from '../../../hooks/useExternalLink'; import { useCheckoutUrl } from '../subscription/hooks/useCheckoutUrl'; import SeatsCapUsage from './SeatsCapUsage'; +import AssignExtensionModal from './voip/AssignExtensionModal'; type UserPageHeaderContentWithSeatsCapProps = { activeUsers: number; @@ -20,6 +21,9 @@ const UserPageHeaderContentWithSeatsCap = ({ }: UserPageHeaderContentWithSeatsCapProps): ReactElement => { const t = useTranslation(); const router = useRouter(); + const setModal = useSetModal(); + + const canRegisterExtension = useSetting('VoIP_TeamCollab_Enabled'); const manageSubscriptionUrl = useCheckoutUrl()({ target: 'user-page', action: 'buy_more' }); const openExternalLink = useExternalLink(); @@ -38,6 +42,11 @@ const UserPageHeaderContentWithSeatsCap = ({ + {canRegisterExtension && ( + + )} diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.spec.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.spec.tsx new file mode 100644 index 000000000000..a1a572ad23a6 --- /dev/null +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.spec.tsx @@ -0,0 +1,98 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { createFakeUser } from '../../../../../tests/mocks/data'; +import UsersTable from './UsersTable'; + +const createFakeAdminUser = (freeSwitchExtension?: string) => + createFakeUser({ + active: true, + roles: ['admin'], + type: 'user', + freeSwitchExtension, + }); + +it('should not render "Voice call extension" column when voice call is disabled', async () => { + const user = createFakeAdminUser('1000'); + + render( + undefined} + tab='all' + onReload={() => undefined} + paginationData={{} as any} + sortData={{} as any} + isSeatsCapExceeded={false} + roleData={undefined} + />, + { + legacyRoot: true, + wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', false).build(), + }, + ); + + expect(screen.queryByText('Voice_call_extension')).not.toBeInTheDocument(); + + screen.getByRole('button', { name: 'More_actions' }).click(); + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Assign_extension/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Unassign_extension/ })).not.toBeInTheDocument(); +}); + +it('should render "Unassign_extension" button when user has a associated extension', async () => { + const user = createFakeAdminUser('1000'); + + render( + undefined} + tab='all' + onReload={() => undefined} + paginationData={{} as any} + sortData={{} as any} + isSeatsCapExceeded={false} + roleData={undefined} + />, + { + legacyRoot: true, + wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', true).build(), + }, + ); + + expect(screen.getByText('Voice_call_extension')).toBeInTheDocument(); + + screen.getByRole('button', { name: 'More_actions' }).click(); + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Assign_extension/ })).not.toBeInTheDocument(); + expect(screen.getByRole('option', { name: /Unassign_extension/ })).toBeInTheDocument(); +}); + +it('should render "Assign_extension" button when user has no associated extension', async () => { + const user = createFakeAdminUser(); + + render( + undefined} + tab='all' + onReload={() => undefined} + paginationData={{} as any} + sortData={{} as any} + isSeatsCapExceeded={false} + roleData={undefined} + />, + { + legacyRoot: true, + wrapper: mockAppRoot().withUser(user).withSetting('VoIP_TeamCollab_Enabled', true).build(), + }, + ); + + expect(screen.getByText('Voice_call_extension')).toBeInTheDocument(); + + screen.getByRole('button', { name: 'More_actions' }).click(); + expect(await screen.findByRole('listbox')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /Assign_extension/ })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Unassign_extension/ })).not.toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx index abdf8cb787c3..531669ca584a 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTable.tsx @@ -3,7 +3,7 @@ import { Pagination } from '@rocket.chat/fuselage'; import { useEffectEvent, useBreakpoints } from '@rocket.chat/fuselage-hooks'; import type { PaginatedResult, DefaultUserInfo } from '@rocket.chat/rest-typings'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; -import { useRouter, useTranslation } from '@rocket.chat/ui-contexts'; +import { useRouter, useTranslation, useSetting } from '@rocket.chat/ui-contexts'; import type { UseQueryResult } from '@tanstack/react-query'; import type { ReactElement, Dispatch, SetStateAction } from 'react'; import React, { useMemo } from 'react'; @@ -18,18 +18,18 @@ import { } from '../../../../components/GenericTable'; import type { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; import type { useSort } from '../../../../components/GenericTable/hooks/useSort'; -import type { AdminUserTab, UsersFilters, UsersTableSortingOptions } from '../AdminUsersPage'; +import type { AdminUsersTab, UsersFilters, UsersTableSortingOption } from '../AdminUsersPage'; import UsersTableFilters from './UsersTableFilters'; import UsersTableRow from './UsersTableRow'; type UsersTableProps = { - tab: AdminUserTab; + tab: AdminUsersTab; roleData: { roles: IRole[] } | undefined; onReload: () => void; setUserFilters: Dispatch>; filteredUsersQueryResult: UseQueryResult[] }>>; paginationData: ReturnType; - sortData: ReturnType>; + sortData: ReturnType>; isSeatsCapExceeded: boolean; }; @@ -49,6 +49,7 @@ const UsersTable = ({ const isMobile = !breakpoints.includes('xl'); const isLaptop = !breakpoints.includes('xxl'); + const isVoIPEnabled = useSetting('VoIP_TeamCollab_Enabled') || false; const { data, isLoading, isError, isSuccess } = filteredUsersQueryResult; @@ -111,9 +112,21 @@ const UsersTable = ({ {t('Pending_action')} ), - , + tab === 'all' && isVoIPEnabled && ( + + {t('Voice_call_extension')} + + ), + , ], - [isLaptop, isMobile, setSort, sortBy, sortDirection, t, tab], + [isLaptop, isMobile, setSort, sortBy, sortDirection, t, tab, isVoIPEnabled], ); return ( @@ -156,6 +169,7 @@ const UsersTable = ({ onReload={onReload} tab={tab} isSeatsCapExceeded={isSeatsCapExceeded} + showVoipExtension={isVoIPEnabled} /> ))} diff --git a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx index c8fa1eae7704..8dc5b4472e1b 100644 --- a/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx +++ b/apps/meteor/client/views/admin/users/UsersTable/UsersTableRow.tsx @@ -7,16 +7,17 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Roles } from '../../../../../app/models/client'; +import { Roles } from '../../../../../app/models/client/models/Roles'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; import { UserStatus } from '../../../../components/UserStatus'; -import type { AdminUserTab } from '../AdminUsersPage'; +import type { AdminUsersTab } from '../AdminUsersPage'; import { useChangeAdminStatusAction } from '../hooks/useChangeAdminStatusAction'; import { useChangeUserStatusAction } from '../hooks/useChangeUserStatusAction'; import { useDeleteUserAction } from '../hooks/useDeleteUserAction'; import { useResetE2EEKeyAction } from '../hooks/useResetE2EEKeyAction'; import { useResetTOTPAction } from '../hooks/useResetTOTPAction'; import { useSendWelcomeEmailMutation } from '../hooks/useSendWelcomeEmailMutation'; +import { useVoipExtensionAction } from '../hooks/useVoipExtensionAction'; type UsersTableRowProps = { user: Serialized; @@ -24,14 +25,24 @@ type UsersTableRowProps = { isMobile: boolean; isLaptop: boolean; onReload: () => void; - tab: AdminUserTab; + tab: AdminUsersTab; isSeatsCapExceeded: boolean; + showVoipExtension: boolean; }; -const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSeatsCapExceeded }: UsersTableRowProps): ReactElement => { +const UsersTableRow = ({ + user, + onClick, + onReload, + isMobile, + isLaptop, + tab, + isSeatsCapExceeded, + showVoipExtension, +}: UsersTableRowProps): ReactElement => { const { t } = useTranslation(); - const { _id, emails, username, name, roles, status, active, avatarETag, lastLogin, type } = user; + const { _id, emails, username = '', name = '', roles, status, active, avatarETag, lastLogin, type, freeSwitchExtension } = user; const registrationStatusText = useMemo(() => { const usersExcludedFromPending = ['bot', 'app']; @@ -64,10 +75,17 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea const resetTOTPAction = useResetTOTPAction(userId); const resetE2EKeyAction = useResetE2EEKeyAction(userId); const resendWelcomeEmail = useSendWelcomeEmailMutation(); + const voipExtensionAction = useVoipExtensionAction({ extension: freeSwitchExtension, username, name }); const isNotPendingDeactivatedNorFederated = tab !== 'pending' && tab !== 'deactivated' && !isFederatedUser; const menuOptions = useMemo( () => ({ + ...(voipExtensionAction && { + voipExtensionAction: { + label: { label: voipExtensionAction.label, icon: voipExtensionAction.icon }, + action: voipExtensionAction.action, + }, + }), ...(isNotPendingDeactivatedNorFederated && changeAdminStatusAction && { makeAdmin: { @@ -102,6 +120,7 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea isNotPendingDeactivatedNorFederated, resetE2EKeyAction, resetTOTPAction, + voipExtensionAction, ], ); @@ -154,6 +173,12 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea )} + {tab === 'all' && showVoipExtension && username && ( + + {freeSwitchExtension || t('Not_assigned')} + + )} + { e.stopPropagation(); @@ -179,6 +204,8 @@ const UsersTableRow = ({ user, onClick, onReload, isMobile, isLaptop, tab, isSea placement='bottom-start' flexShrink={0} key='menu' + aria-label={t('More_actions')} + title={t('More_actions')} renderItem={({ label: { label, icon }, ...props }): ReactElement => (