From 60638b94e5a44e05e0bda9dbcf5e36e617455afd Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Fri, 1 Jul 2022 14:20:13 -0300 Subject: [PATCH] [BREAK] VideoConference (#25570) Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com> Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com> Co-authored-by: Guilherme Gazzo <5263975+ggazzo@users.noreply.github.com> --- apps/meteor/.mocharc.api.js | 1 + .../app/api/server/v1/videoConference.ts | 183 +++- .../meteor/app/apps/server/bridges/bridges.js | 6 + .../apps/server/bridges/videoConferences.ts | 70 ++ .../app/apps/server/converters/index.js | 3 +- .../server/converters/videoConferences.ts | 36 + apps/meteor/app/apps/server/orchestrator.js | 9 +- .../bigbluebutton/server/bigbluebutton-api.js | 188 ---- apps/meteor/app/bigbluebutton/server/index.js | 1 - .../custom-sounds/client/lib/CustomSounds.js | 7 + .../app/lib/server/functions/createRoom.ts | 2 +- .../lib/server/functions/saveUserIdentity.ts | 4 + apps/meteor/app/livechat/client/index.js | 1 - .../app/livechat/client/lib/messageTypes.js | 5 - .../app/livechat/server/api/lib/livechat.js | 6 +- .../app/livechat/server/api/v1/videoCall.js | 61 +- apps/meteor/app/livechat/server/index.js | 2 - .../app/livechat/server/lib/Livechat.js | 6 +- .../app/livechat/server/lib/messageTypes.js | 26 - .../livechat/server/methods/startVideoCall.js | 58 -- apps/meteor/app/models/server/models/Rooms.js | 14 - .../server/functions/validateSetting.ts | 3 +- .../app/statistics/server/lib/statistics.ts | 3 +- apps/meteor/app/ui-sidenav/client/roomList.js | 13 +- .../app/videobridge/client/actionLink.js | 68 -- apps/meteor/app/videobridge/client/index.js | 4 - apps/meteor/app/videobridge/client/index.ts | 1 + apps/meteor/app/videobridge/client/tabBar.tsx | 143 ++- .../videobridge/client/views/bbbLiveView.html | 3 - apps/meteor/app/videobridge/constants.js | 3 - .../meteor/app/videobridge/lib/messageType.js | 12 - .../app/videobridge/server/actionLink.js | 3 - apps/meteor/app/videobridge/server/index.js | 6 - .../app/videobridge/server/methods/bbb.js | 187 ---- .../server/methods/jitsiGenerateToken.js | 79 -- .../server/methods/jitsiSetTimeout.js | 86 -- .../meteor/app/videobridge/server/settings.ts | 226 ----- .../meteor/client/components/GenericModal.tsx | 4 +- .../client/components/RoomIcon/RoomIcon.tsx | 6 + .../client/components/modal/ModalPortal.tsx | 3 + .../client/contexts/VideoConfContext.ts | 74 ++ apps/meteor/client/lib/VideoConfManager.ts | 680 +++++++++++++ .../client/portals/VideoConfPopupPortal.ts | 17 + .../providers/CallProvider/CallProvider.tsx | 6 +- .../CallProvider/hooks/useVoipClient.ts | 4 +- .../client/providers/MeteorProvider.tsx | 17 +- .../client/providers/VideoConfProvider.tsx | 82 ++ apps/meteor/client/sidebar/RoomList/Row.tsx | 19 +- .../RoomList/SideBarItemTemplateWithData.tsx | 19 +- .../client/sidebar/footer/voip/index.tsx | 4 +- .../client/sidebar/hooks/useRoomList.ts | 10 + .../views/admin/apps/AppDetailsHeader.tsx | 18 +- .../admin/info/DeploymentCard.stories.tsx | 22 + .../admin/info/InformationPage.stories.tsx | 22 + .../views/admin/info/UsageCard.stories.tsx | 22 + .../client/views/admin/info/UsageCard.tsx | 4 +- .../views/admin/settings/MemoizedSetting.tsx | 2 + .../settings/inputs/LookupSettingInput.tsx | 70 ++ .../client/views/blocks/MessageBlock.js | 15 +- .../VideoConference/BBB/CallBBB.tsx | 57 -- .../contextualBar/VideoConference/BBB/D.tsx | 71 -- .../VideoConference/BBB/index.ts | 1 - .../Jitsi/CallJitsi.stories.tsx | 20 - .../VideoConference/Jitsi/CallJitsi.tsx | 44 - .../Jitsi/CallJitsiWithData.tsx | 235 ----- .../VideoConference/Jitsi/index.ts | 1 - .../VideoConference/Jitsi/lib/Jitsi.js | 375 -------- .../VideoConference/Jitsi/lib/JitsiBridge.js | 82 -- .../VideoConference/VideoConfBlockModal.tsx | 41 + .../VideoConfList/VideoConfList.tsx | 82 ++ .../VideoConfList/VideoConfListItem.tsx | 108 +++ .../VideoConfList/VideoConfListWithData.tsx | 29 + .../VideoConfList/VideoConfRecordList.ts | 9 + .../VideoConference/VideoConfList/index.ts | 1 + .../VideoConfList/useVideoConfList.ts | 52 + .../VideoConfPopup/CallingPopup.tsx | 101 ++ .../VideoConfPopup/ReceivingPopup.tsx | 111 +++ .../StartCallPopup/StartCallPopup.tsx | 46 + .../StartCallPopup/StartDirectCallPopup.tsx | 101 ++ .../StartCallPopup/StartGroupCallPopup.tsx | 97 ++ .../StartOmnichannelCallPopup.tsx | 44 + .../VideoConfPopup/StartCallPopup/index.ts | 1 + .../VideoConfPopup/TimedVideoConfPopup.tsx | 94 ++ .../VideoConfPopups/VideoConfPopup/index.ts | 1 + .../VideoConfPopups/VideoConfPopups.tsx | 64 ++ .../VideoConference/VideoConfPopups/index.ts | 1 + .../contextualBar/VideoConference/index.ts | 1 + .../VideoConference/useVideoConfWarning.tsx | 78 ++ .../client/views/room/lib/Toolbox/index.tsx | 1 + .../channels/hooks/useTeamsChannelList.ts | 3 +- .../ee/app/canned-responses/client/tabBar.ts | 4 +- apps/meteor/ee/app/license/server/bundles.ts | 4 +- .../ee/client/hooks/useDevicesMenuOption.tsx | 4 +- ...seHasLicense.ts => useHasLicenseModule.ts} | 2 +- .../monitors/MonitorsPageContainer.js | 4 +- apps/meteor/ee/server/configuration/index.ts | 1 + .../server/configuration/videoConference.ts | 52 + apps/meteor/ee/server/settings/index.ts | 1 + .../ee/server/settings/video-conference.ts | 37 + apps/meteor/lib/callbacks.ts | 2 + apps/meteor/lib/videoConference/constants.ts | 5 + apps/meteor/package.json | 5 +- .../rocketchat-i18n/i18n/en.i18n.json | 76 +- apps/meteor/public/sounds/calling.mp3 | Bin 0 -> 169187 bytes apps/meteor/server/cron/videoConferences.ts | 30 + apps/meteor/server/importPackages.ts | 2 - .../lib/statistics/getSettingsStatistics.ts | 8 - apps/meteor/server/lib/videoConfProviders.ts | 59 ++ apps/meteor/server/lib/videoConfTypes.ts | 39 + apps/meteor/server/models/VideoConference.ts | 7 + apps/meteor/server/models/raw/Messages.ts | 29 + .../meteor/server/models/raw/Subscriptions.ts | 8 + .../server/models/raw/VideoConference.ts | 273 ++++++ apps/meteor/server/models/startup.ts | 1 + .../modules/core-apps/videoconf.module.ts | 18 + .../server/modules/watchers/publishFields.ts | 1 - apps/meteor/server/sdk/index.ts | 2 + .../server/sdk/types/IVideoConfService.ts | 36 + apps/meteor/server/services/startup.ts | 2 + .../services/video-conference/service.ts | 907 ++++++++++++++++++ apps/meteor/server/settings/index.ts | 1 + .../server/settings/video-conference.ts | 19 + apps/meteor/server/startup/coreApps.ts | 2 + apps/meteor/server/startup/cron.js | 2 + .../meteor/server/startup/migrations/index.ts | 1 + apps/meteor/server/startup/migrations/v275.ts | 40 + apps/meteor/tests/data/apps/apps-data.js | 2 +- .../tests/e2e/04-main-elements-render.spec.ts | 4 + apps/meteor/tests/e2e/pageobjects/FlexTab.ts | 2 +- .../end-to-end/api/15-video-conference.js | 51 - .../tests/end-to-end/apps/00-installation.js | 16 + .../end-to-end/apps/05-video-conferences.ts | 420 ++++++++ .../VideoConfCancelProps.spec.ts | 40 + .../VideoConfInfoProps.spec.ts | 40 + .../VideoConfJoinProps.spec.ts | 45 + .../VideoConfListProps.spec.ts | 50 + .../VideoConfStartProps.spec.ts | 58 ++ packages/core-typings/src/IRoom.ts | 1 - packages/core-typings/src/ISetting.ts | 6 +- packages/core-typings/src/IStats.ts | 22 + packages/core-typings/src/IVideoConference.ts | 98 ++ .../src/VideoConferenceCapabilities.ts | 5 + .../src/VideoConferenceOptions.ts | 4 + packages/core-typings/src/index.ts | 3 + .../src/components/Calls/CallNotification.js | 2 +- .../src/components/Calls/JoinCallButton.js | 2 +- .../components/Messages/MessageList/index.js | 14 +- .../src/components/Messages/constants.js | 1 - packages/livechat/src/lib/constants.js | 1 - packages/livechat/src/lib/room.js | 44 +- packages/model-typings/src/index.ts | 1 + .../src/models/IMessagesModel.ts | 6 + .../src/models/ISubscriptionsModel.ts | 2 + .../src/models/IVideoConferenceModel.ts | 61 ++ packages/models/src/index.ts | 2 + packages/rest-typings/src/index.ts | 1 + .../rest-typings/src/v1/videoConference.ts | 35 - .../videoConference/VideoConfCancelProps.ts | 21 + .../v1/videoConference/VideoConfInfoProps.ts | 19 + .../v1/videoConference/VideoConfJoinProps.ts | 40 + .../v1/videoConference/VideoConfListProps.ts | 25 + .../v1/videoConference/VideoConfStartProps.ts | 27 + .../src/v1/videoConference/index.ts | 44 + .../ui-contexts/src/CustomSoundContext.ts | 2 + .../ui-contexts/src/ServerContext/methods.ts | 2 - packages/ui-video-conf/.eslintrc | 73 ++ packages/ui-video-conf/package.json | 37 + .../ui-video-conf/src/VideoConfButton.tsx | 21 + .../ui-video-conf/src/VideoConfController.tsx | 20 + .../src/VideoConfPopup/VideoConfPopup.tsx | 31 + .../VideoConfPopup/VideoConfPopupBackdrop.tsx | 15 + .../VideoConfPopup/VideoConfPopupClose.tsx | 11 + .../VideoConfPopup/VideoConfPopupContent.tsx | 11 + .../VideoConfPopupControllers.tsx | 7 + .../VideoConfPopup/VideoConfPopupFooter.tsx | 7 + .../VideoConfPopupFooterButtons.tsx | 7 + .../VideoConfPopupIndicators.tsx | 7 + .../VideoConfPopup/VideoConfPopupTitle.tsx | 23 + .../VideoConfPopup/VideoConfPopupUsername.tsx | 18 + .../ui-video-conf/src/VideoConfPopup/index.ts | 23 + packages/ui-video-conf/src/hooks/index.ts | 1 + .../src/hooks/useVideoConfControllers.ts | 17 + packages/ui-video-conf/src/index.ts | 6 + packages/ui-video-conf/tsconfig.json | 8 + yarn.lock | 93 +- 185 files changed, 5710 insertions(+), 2230 deletions(-) create mode 100644 apps/meteor/app/apps/server/bridges/videoConferences.ts create mode 100644 apps/meteor/app/apps/server/converters/videoConferences.ts delete mode 100644 apps/meteor/app/bigbluebutton/server/bigbluebutton-api.js delete mode 100644 apps/meteor/app/bigbluebutton/server/index.js delete mode 100644 apps/meteor/app/livechat/client/lib/messageTypes.js delete mode 100644 apps/meteor/app/livechat/server/lib/messageTypes.js delete mode 100644 apps/meteor/app/livechat/server/methods/startVideoCall.js delete mode 100644 apps/meteor/app/videobridge/client/actionLink.js delete mode 100644 apps/meteor/app/videobridge/client/index.js create mode 100644 apps/meteor/app/videobridge/client/index.ts delete mode 100644 apps/meteor/app/videobridge/client/views/bbbLiveView.html delete mode 100644 apps/meteor/app/videobridge/constants.js delete mode 100644 apps/meteor/app/videobridge/lib/messageType.js delete mode 100644 apps/meteor/app/videobridge/server/actionLink.js delete mode 100644 apps/meteor/app/videobridge/server/index.js delete mode 100644 apps/meteor/app/videobridge/server/methods/bbb.js delete mode 100644 apps/meteor/app/videobridge/server/methods/jitsiGenerateToken.js delete mode 100644 apps/meteor/app/videobridge/server/methods/jitsiSetTimeout.js delete mode 100644 apps/meteor/app/videobridge/server/settings.ts create mode 100644 apps/meteor/client/contexts/VideoConfContext.ts create mode 100644 apps/meteor/client/lib/VideoConfManager.ts create mode 100644 apps/meteor/client/portals/VideoConfPopupPortal.ts create mode 100644 apps/meteor/client/providers/VideoConfProvider.tsx create mode 100644 apps/meteor/client/views/admin/settings/inputs/LookupSettingInput.tsx delete mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/BBB/CallBBB.tsx delete mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/BBB/D.tsx delete mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/BBB/index.ts delete mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/Jitsi/CallJitsi.stories.tsx delete mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/Jitsi/CallJitsi.tsx delete mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/Jitsi/CallJitsiWithData.tsx delete mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/Jitsi/index.ts delete mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/Jitsi/lib/Jitsi.js delete mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/Jitsi/lib/JitsiBridge.js create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfBlockModal.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfList.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListItem.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfListWithData.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/VideoConfRecordList.ts create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/index.ts create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfList/useVideoConfList.ts create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopup/CallingPopup.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopup/ReceivingPopup.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopup/StartCallPopup/StartCallPopup.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopup/StartCallPopup/StartDirectCallPopup.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopup/StartCallPopup/StartGroupCallPopup.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopup/StartCallPopup/StartOmnichannelCallPopup.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopup/StartCallPopup/index.ts create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopup/TimedVideoConfPopup.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopup/index.ts create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopups.tsx create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/VideoConfPopups/index.ts create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/index.ts create mode 100644 apps/meteor/client/views/room/contextualBar/VideoConference/useVideoConfWarning.tsx rename apps/meteor/ee/client/hooks/{useHasLicense.ts => useHasLicenseModule.ts} (79%) create mode 100644 apps/meteor/ee/server/configuration/videoConference.ts create mode 100644 apps/meteor/ee/server/settings/video-conference.ts create mode 100644 apps/meteor/lib/videoConference/constants.ts create mode 100644 apps/meteor/public/sounds/calling.mp3 create mode 100644 apps/meteor/server/cron/videoConferences.ts create mode 100644 apps/meteor/server/lib/videoConfProviders.ts create mode 100644 apps/meteor/server/lib/videoConfTypes.ts create mode 100644 apps/meteor/server/models/VideoConference.ts create mode 100644 apps/meteor/server/models/raw/VideoConference.ts create mode 100644 apps/meteor/server/modules/core-apps/videoconf.module.ts create mode 100644 apps/meteor/server/sdk/types/IVideoConfService.ts create mode 100644 apps/meteor/server/services/video-conference/service.ts create mode 100644 apps/meteor/server/settings/video-conference.ts create mode 100644 apps/meteor/server/startup/migrations/v275.ts delete mode 100644 apps/meteor/tests/end-to-end/api/15-video-conference.js create mode 100644 apps/meteor/tests/end-to-end/apps/05-video-conferences.ts create mode 100644 apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfCancelProps.spec.ts create mode 100644 apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfInfoProps.spec.ts create mode 100644 apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfJoinProps.spec.ts create mode 100644 apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfListProps.spec.ts create mode 100644 apps/meteor/tests/unit/definition/rest/v1/video-conference/VideoConfStartProps.spec.ts create mode 100644 packages/core-typings/src/IVideoConference.ts create mode 100644 packages/core-typings/src/VideoConferenceCapabilities.ts create mode 100644 packages/core-typings/src/VideoConferenceOptions.ts create mode 100644 packages/model-typings/src/models/IVideoConferenceModel.ts delete mode 100644 packages/rest-typings/src/v1/videoConference.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfCancelProps.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfInfoProps.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfJoinProps.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfListProps.ts create mode 100644 packages/rest-typings/src/v1/videoConference/VideoConfStartProps.ts create mode 100644 packages/rest-typings/src/v1/videoConference/index.ts create mode 100644 packages/ui-video-conf/.eslintrc create mode 100644 packages/ui-video-conf/package.json create mode 100644 packages/ui-video-conf/src/VideoConfButton.tsx create mode 100644 packages/ui-video-conf/src/VideoConfController.tsx create mode 100644 packages/ui-video-conf/src/VideoConfPopup/VideoConfPopup.tsx create mode 100644 packages/ui-video-conf/src/VideoConfPopup/VideoConfPopupBackdrop.tsx create mode 100644 packages/ui-video-conf/src/VideoConfPopup/VideoConfPopupClose.tsx create mode 100644 packages/ui-video-conf/src/VideoConfPopup/VideoConfPopupContent.tsx create mode 100644 packages/ui-video-conf/src/VideoConfPopup/VideoConfPopupControllers.tsx create mode 100644 packages/ui-video-conf/src/VideoConfPopup/VideoConfPopupFooter.tsx create mode 100644 packages/ui-video-conf/src/VideoConfPopup/VideoConfPopupFooterButtons.tsx create mode 100644 packages/ui-video-conf/src/VideoConfPopup/VideoConfPopupIndicators.tsx create mode 100644 packages/ui-video-conf/src/VideoConfPopup/VideoConfPopupTitle.tsx create mode 100644 packages/ui-video-conf/src/VideoConfPopup/VideoConfPopupUsername.tsx create mode 100644 packages/ui-video-conf/src/VideoConfPopup/index.ts create mode 100644 packages/ui-video-conf/src/hooks/index.ts create mode 100644 packages/ui-video-conf/src/hooks/useVideoConfControllers.ts create mode 100644 packages/ui-video-conf/src/index.ts create mode 100644 packages/ui-video-conf/tsconfig.json diff --git a/apps/meteor/.mocharc.api.js b/apps/meteor/.mocharc.api.js index c7994105d68a..6fb62970b3f4 100644 --- a/apps/meteor/.mocharc.api.js +++ b/apps/meteor/.mocharc.api.js @@ -14,5 +14,6 @@ module.exports = { 'tests/end-to-end/api/*.js', 'tests/end-to-end/api/*.ts', 'tests/end-to-end/apps/*.js', + 'tests/end-to-end/apps/*.ts', ], }; diff --git a/apps/meteor/app/api/server/v1/videoConference.ts b/apps/meteor/app/api/server/v1/videoConference.ts index 34a78fdf09da..1f76fa4b7366 100644 --- a/apps/meteor/app/api/server/v1/videoConference.ts +++ b/apps/meteor/app/api/server/v1/videoConference.ts @@ -1,25 +1,182 @@ -import { Meteor } from 'meteor/meteor'; +import type { VideoConference } from '@rocket.chat/core-typings'; +import { + isVideoConfStartProps, + isVideoConfJoinProps, + isVideoConfCancelProps, + isVideoConfInfoProps, + isVideoConfListProps, +} from '@rocket.chat/rest-typings'; -import { Rooms } from '../../../models/server'; import { API } from '../api'; +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { VideoConf } from '../../../../server/sdk'; +import { videoConfProviders } from '../../../../server/lib/videoConfProviders'; +import { availabilityErrors } from '../../../../lib/videoConference/constants'; API.v1.addRoute( - 'video-conference/jitsi.update-timeout', - { authRequired: true }, + 'video-conference.start', + { authRequired: true, validateParams: isVideoConfStartProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 } }, { - post() { - const { roomId, joiningNow = true } = this.bodyParams; - if (!roomId) { - return API.v1.failure('The "roomId" parameter is required!'); + async post() { + const { roomId, title, allowRinging } = this.bodyParams; + const { userId } = this; + if (!userId || !(await canAccessRoomIdAsync(roomId, userId))) { + return API.v1.failure('invalid-params'); } - const room = Rooms.findOneById(roomId, { fields: { _id: 1 } }); - if (!room) { - return API.v1.failure('Room does not exist!'); + try { + const providerName = videoConfProviders.getActiveProvider(); + + if (!providerName) { + throw new Error(availabilityErrors.NOT_ACTIVE); + } + + return API.v1.success({ + data: { + ...(await VideoConf.start(userId, roomId, { title, allowRinging: Boolean(allowRinging) })), + providerName, + }, + }); + } catch (e) { + return API.v1.failure(await VideoConf.diagnoseProvider(userId, roomId)); + } + }, + }, +); + +API.v1.addRoute( + 'video-conference.join', + { authOrAnonRequired: true, validateParams: isVideoConfJoinProps, rateLimiterOptions: { numRequestsAllowed: 2, intervalTimeInMS: 5000 } }, + { + async post() { + const { callId, state } = this.bodyParams; + const { userId } = this; + + const call = await VideoConf.get(callId); + if (!call) { + return API.v1.failure('invalid-params'); + } + + if (!(await canAccessRoomIdAsync(call.rid, userId))) { + return API.v1.failure('invalid-params'); + } + + let url: string | undefined; + + try { + url = await VideoConf.join(userId, callId, { + ...(state?.cam !== undefined ? { cam: state.cam } : {}), + ...(state?.mic !== undefined ? { mic: state.mic } : {}), + }); + } catch (e) { + if (userId) { + return API.v1.failure(await VideoConf.diagnoseProvider(userId, call.rid, call.providerName)); + } + } + + if (!url) { + return API.v1.failure('failed-to-get-url'); } - const jitsiTimeout = Meteor.runAsUser(this.userId, () => Meteor.call('jitsi:updateTimeout', roomId, Boolean(joiningNow))); - return API.v1.success({ jitsiTimeout }); + return API.v1.success({ + url, + providerName: call.providerName, + }); + }, + }, +); + +API.v1.addRoute( + 'video-conference.cancel', + { authRequired: true, validateParams: isVideoConfCancelProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 60000 } }, + { + async post() { + const { callId } = this.bodyParams; + const { userId } = this; + + const call = await VideoConf.get(callId); + if (!call) { + return API.v1.failure('invalid-params'); + } + + if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) { + return API.v1.failure('invalid-params'); + } + + await VideoConf.cancel(userId, callId); + return API.v1.success(); + }, + }, +); + +API.v1.addRoute( + 'video-conference.info', + { authRequired: true, validateParams: isVideoConfInfoProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } }, + { + async get() { + const { callId } = this.queryParams; + const { userId } = this; + + const call = await VideoConf.get(callId); + if (!call) { + return API.v1.failure('invalid-params'); + } + + if (!userId || !(await canAccessRoomIdAsync(call.rid, userId))) { + return API.v1.failure('invalid-params'); + } + + const capabilities = await VideoConf.listProviderCapabilities(call.providerName); + + return API.v1.success({ + ...(call as VideoConference), + capabilities, + }); + }, + }, +); + +API.v1.addRoute( + 'video-conference.list', + { authRequired: true, validateParams: isVideoConfListProps, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } }, + { + async get() { + const { roomId } = this.queryParams; + const { userId } = this; + + const { offset, count } = this.getPaginationItems(); + + if (!userId || !(await canAccessRoomIdAsync(roomId, userId))) { + return API.v1.failure('invalid-params'); + } + + const data = await VideoConf.list(roomId, { offset, count }); + + return API.v1.success(data); + }, + }, +); + +API.v1.addRoute( + 'video-conference.providers', + { authRequired: true, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } }, + { + async get() { + const data = await VideoConf.listProviders(); + + return API.v1.success({ data }); + }, + }, +); + +API.v1.addRoute( + 'video-conference.capabilities', + { authRequired: true, rateLimiterOptions: { numRequestsAllowed: 3, intervalTimeInMS: 1000 } }, + { + async get() { + const data = await VideoConf.listCapabilities(); + + return API.v1.success(data); }, }, ); diff --git a/apps/meteor/app/apps/server/bridges/bridges.js b/apps/meteor/app/apps/server/bridges/bridges.js index d03160e02d0f..828be594640d 100644 --- a/apps/meteor/app/apps/server/bridges/bridges.js +++ b/apps/meteor/app/apps/server/bridges/bridges.js @@ -18,6 +18,7 @@ import { AppLivechatBridge } from './livechat'; import { AppUploadBridge } from './uploads'; import { UiInteractionBridge } from './uiInteraction'; import { AppSchedulerBridge } from './scheduler'; +import { AppVideoConferenceBridge } from './videoConferences'; export class RealAppBridges extends AppBridges { constructor(orch) { @@ -41,6 +42,7 @@ export class RealAppBridges extends AppBridges { this._uiInteractionBridge = new UiInteractionBridge(orch); this._schedulerBridge = new AppSchedulerBridge(orch); this._cloudWorkspaceBridge = new AppCloudBridge(orch); + this._videoConfBridge = new AppVideoConferenceBridge(orch); } getCommandBridge() { @@ -114,4 +116,8 @@ export class RealAppBridges extends AppBridges { getCloudWorkspaceBridge() { return this._cloudWorkspaceBridge; } + + getVideoConferenceBridge() { + return this._videoConfBridge; + } } diff --git a/apps/meteor/app/apps/server/bridges/videoConferences.ts b/apps/meteor/app/apps/server/bridges/videoConferences.ts new file mode 100644 index 000000000000..9d7bf245e73b --- /dev/null +++ b/apps/meteor/app/apps/server/bridges/videoConferences.ts @@ -0,0 +1,70 @@ +import { VideoConferenceBridge } from '@rocket.chat/apps-engine/server/bridges/VideoConferenceBridge'; +import { AppVideoConference, VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; +import { IVideoConfProvider } from '@rocket.chat/apps-engine/definition/videoConfProviders'; + +import { VideoConf } from '../../../../server/sdk'; +import { AppServerOrchestrator } from '../orchestrator'; +import { videoConfProviders } from '../../../../server/lib/videoConfProviders'; +import type { AppVideoConferencesConverter } from '../converters/videoConferences'; + +export class AppVideoConferenceBridge extends VideoConferenceBridge { + // eslint-disable-next-line no-empty-function + constructor(private readonly orch: AppServerOrchestrator) { + super(); + } + + protected async getById(callId: string, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is getting the video conference byId: "${callId}"`); + + return this.orch.getConverters()?.get('videoConferences').convertById(callId); + } + + protected async create(call: AppVideoConference, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is creating a video conference.`); + + return ( + await VideoConf.create({ + type: 'videoconference', + ...call, + }) + ).callId; + } + + protected async update(call: VideoConference, appId: string): Promise { + this.orch.debugLog(`The App ${appId} is updating a video conference.`); + + const oldData = call._id && (await VideoConf.getUnfiltered(call._id)); + if (!oldData) { + throw new Error('A video conference must exist to update.'); + } + + const data = (this.orch.getConverters()?.get('videoConferences') as AppVideoConferencesConverter).convertAppVideoConference(call); + await VideoConf.setProviderData(call._id, data.providerData); + + for (const { _id, ts } of data.users) { + if (oldData.users.find((user) => user._id === _id)) { + continue; + } + + VideoConf.addUser(call._id, _id, ts); + } + + if (data.endedBy && data.endedBy._id !== oldData.endedBy?._id) { + await VideoConf.setEndedBy(call._id, data.endedBy._id); + } else if (data.endedAt) { + await VideoConf.setEndedAt(call._id, data.endedAt); + } + + if (data.status > oldData.status) { + await VideoConf.setStatus(call._id, data.status); + } + } + + protected async registerProvider(info: IVideoConfProvider): Promise { + videoConfProviders.registerProvider(info.name, info.capabilities || {}); + } + + protected async unRegisterProvider(info: IVideoConfProvider): Promise { + videoConfProviders.unRegisterProvider(info.name); + } +} diff --git a/apps/meteor/app/apps/server/converters/index.js b/apps/meteor/app/apps/server/converters/index.js index edb1bcf57cb1..d5fe67636dc0 100644 --- a/apps/meteor/app/apps/server/converters/index.js +++ b/apps/meteor/app/apps/server/converters/index.js @@ -2,5 +2,6 @@ import { AppMessagesConverter } from './messages'; import { AppRoomsConverter } from './rooms'; import { AppSettingsConverter } from './settings'; import { AppUsersConverter } from './users'; +import { AppVideoConferencesConverter } from './videoConferences'; -export { AppMessagesConverter, AppRoomsConverter, AppSettingsConverter, AppUsersConverter }; +export { AppMessagesConverter, AppRoomsConverter, AppSettingsConverter, AppUsersConverter, AppVideoConferencesConverter }; diff --git a/apps/meteor/app/apps/server/converters/videoConferences.ts b/apps/meteor/app/apps/server/converters/videoConferences.ts new file mode 100644 index 000000000000..dd4e2c113b6f --- /dev/null +++ b/apps/meteor/app/apps/server/converters/videoConferences.ts @@ -0,0 +1,36 @@ +import type { VideoConference } from '@rocket.chat/apps-engine/definition/videoConferences'; +import type { IVideoConference } from '@rocket.chat/core-typings'; + +import { VideoConf } from '../../../../server/sdk'; +import type { AppServerOrchestrator } from '../orchestrator'; + +export class AppVideoConferencesConverter { + // @ts-ignore + private orch: AppServerOrchestrator; + + constructor(orch: AppServerOrchestrator) { + this.orch = orch; + } + + async convertById(callId: string): Promise { + const call = await VideoConf.getUnfiltered(callId); + + return this.convertVideoConference(call); + } + + convertVideoConference(call: IVideoConference | null): VideoConference | undefined { + if (!call) { + return; + } + + return { + ...call, + } as VideoConference; + } + + convertAppVideoConference(call: VideoConference): IVideoConference { + return { + ...call, + } as IVideoConference; + } +} diff --git a/apps/meteor/app/apps/server/orchestrator.js b/apps/meteor/app/apps/server/orchestrator.js index 85a46fdc4a16..9d3bdf7a35a9 100644 --- a/apps/meteor/app/apps/server/orchestrator.js +++ b/apps/meteor/app/apps/server/orchestrator.js @@ -8,7 +8,13 @@ import { AppsLogsModel, AppsModel, AppsPersistenceModel } from '../../models/ser import { settings, settingsRegistry } from '../../settings/server'; import { RealAppBridges } from './bridges'; import { AppMethods, AppServerNotifier, AppsRestApi, AppUIKitInteractionApi } from './communication'; -import { AppMessagesConverter, AppRoomsConverter, AppSettingsConverter, AppUsersConverter } from './converters'; +import { + AppMessagesConverter, + AppRoomsConverter, + AppSettingsConverter, + AppUsersConverter, + AppVideoConferencesConverter, +} from './converters'; import { AppDepartmentsConverter } from './converters/departments'; import { AppUploadsConverter } from './converters/uploads'; import { AppVisitorsConverter } from './converters/visitors'; @@ -54,6 +60,7 @@ export class AppServerOrchestrator { this._converters.set('visitors', new AppVisitorsConverter(this)); this._converters.set('departments', new AppDepartmentsConverter(this)); this._converters.set('uploads', new AppUploadsConverter(this)); + this._converters.set('videoConferences', new AppVideoConferencesConverter(this)); this._bridges = new RealAppBridges(this); diff --git a/apps/meteor/app/bigbluebutton/server/bigbluebutton-api.js b/apps/meteor/app/bigbluebutton/server/bigbluebutton-api.js deleted file mode 100644 index 8cb3f4d447c4..000000000000 --- a/apps/meteor/app/bigbluebutton/server/bigbluebutton-api.js +++ /dev/null @@ -1,188 +0,0 @@ -/* eslint-disable */ -import crypto from 'crypto'; -import { SystemLogger } from '../../../server/lib/logger/system'; - -var BigBlueButtonApi, filterCustomParameters, include, noChecksumMethods, - __indexOf = [].indexOf || function (item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; - -BigBlueButtonApi = (function () { - function BigBlueButtonApi(url, salt, debug, opts) { - var _base; - if (opts == null) { - opts = {}; - } - this.url = url; - this.salt = salt; - this.opts = opts; - if ((_base = this.opts).shaType == null) { - _base.shaType = 'sha1'; - } - } - - BigBlueButtonApi.prototype.availableApiCalls = function () { - return ['/', 'create', 'join', 'isMeetingRunning', 'getMeetingInfo', 'end', 'getMeetings', 'getDefaultConfigXML', 'setConfigXML', 'enter', 'configXML', 'signOut', 'getRecordings', 'publishRecordings', 'deleteRecordings', 'updateRecordings', 'hooks/create']; - }; - - BigBlueButtonApi.prototype.urlParamsFor = function (param) { - switch (param) { - case "create": - return [["meetingID", true], ["name", true], ["attendeePW", false], ["moderatorPW", false], ["welcome", false], ["dialNumber", false], ["voiceBridge", false], ["webVoice", false], ["logoutURL", false], ["maxParticipants", false], ["record", false], ["duration", false], ["moderatorOnlyMessage", false], ["autoStartRecording", false], ["allowStartStopRecording", false], [/meta_\w+/, false]]; - case "join": - return [["fullName", true], ["meetingID", true], ["password", true], ["createTime", false], ["userID", false], ["webVoiceConf", false], ["configToken", false], ["avatarURL", false], ["redirect", false], ["clientURL", false]]; - case "isMeetingRunning": - return [["meetingID", true]]; - case "end": - return [["meetingID", true], ["password", true]]; - case "getMeetingInfo": - return [["meetingID", true], ["password", true]]; - case "getRecordings": - return [["meetingID", false], ["recordID", false], ["state", false], [/meta_\w+/, false]]; - case "publishRecordings": - return [["recordID", true], ["publish", true]]; - case "deleteRecordings": - return [["recordID", true]]; - case "updateRecordings": - return [["recordID", true], [/meta_\w+/, false]]; - case "hooks/create": - return [["callbackURL", false], ["meetingID", false]]; - } - }; - - BigBlueButtonApi.prototype.filterParams = function (params, method) { - var filters, r; - filters = this.urlParamsFor(method); - if ((filters == null) || filters.length === 0) { - ({}); - } else { - r = include(params, function (key, value) { - var filter, _i, _len; - for (_i = 0, _len = filters.length; _i < _len; _i++) { - filter = filters[_i]; - if (filter[0] instanceof RegExp) { - if (key.match(filter[0]) || key.match(/^custom_/)) { - return true; - } - } else { - if (key.match("^" + filter[0] + "$") || key.match(/^custom_/)) { - return true; - } - } - } - return false; - }); - } - return filterCustomParameters(r); - }; - - BigBlueButtonApi.prototype.urlFor = function (method, params, filter) { - var checksum, key, keys, param, paramList, property, query, sep, url, _i, _len; - if (filter == null) { - filter = true; - } - SystemLogger.debug("Generating URL for", method); - if (filter) { - params = this.filterParams(params, method); - } else { - params = filterCustomParameters(params); - } - url = this.url; - paramList = []; - if (params != null) { - keys = []; - for (property in params) { - keys.push(property); - } - keys = keys.sort(); - for (_i = 0, _len = keys.length; _i < _len; _i++) { - key = keys[_i]; - if (key != null) { - param = params[key]; - } - if (param != null) { - paramList.push("" + (this.encodeForUrl(key)) + "=" + (this.encodeForUrl(param))); - } - } - if (paramList.length > 0) { - query = paramList.join("&"); - } - } else { - query = ''; - } - checksum = this.checksum(method, query); - if (paramList.length > 0) { - query = "" + method + "?" + query; - sep = '&'; - } else { - if (method !== '/') { - query = method; - } - sep = '?'; - } - if (__indexOf.call(noChecksumMethods(), method) < 0) { - query = "" + query + sep + "checksum=" + checksum; - } - return "" + url + "/" + query; - }; - - BigBlueButtonApi.prototype.checksum = function (method, query) { - var c, shaObj, str; - query || (query = ""); - SystemLogger.debug("- Calculating the checksum using: '" + method + "', '" + query + "', '" + this.salt + "'"); - str = method + query + this.salt; - if (this.opts.shaType === 'sha256') { - shaObj = crypto.createHash('sha256', "TEXT") - } else { - shaObj = crypto.createHash('sha1', "TEXT") - } - shaObj.update(str); - c = shaObj.digest('hex'); - SystemLogger.debug("- Checksum calculated:", c); - return c; - }; - - BigBlueButtonApi.prototype.encodeForUrl = function (value) { - return encodeURIComponent(value).replace(/%20/g, '+').replace(/[!'()]/g, escape).replace(/\*/g, "%2A"); - }; - - BigBlueButtonApi.prototype.setMobileProtocol = function (url) { - return url.replace(/http[s]?\:\/\//, "bigbluebutton://"); - }; - - return BigBlueButtonApi; - -})(); - -include = function (input, _function) { - var key, value, _match, _obj; - _obj = new Object; - _match = null; - for (key in input) { - value = input[key]; - if (_function.call(input, key, value)) { - _obj[key] = value; - } - } - return _obj; -}; - -export default BigBlueButtonApi; - -filterCustomParameters = function (params) { - var key, v; - for (key in params) { - v = params[key]; - if (key.match(/^custom_/)) { - params[key.replace(/^custom_/, "")] = v; - } - } - for (key in params) { - if (key.match(/^custom_/)) { - delete params[key]; - } - } - return params; -}; - -noChecksumMethods = function () { - return ['setConfigXML', '/', 'enter', 'configXML', 'signOut']; -}; diff --git a/apps/meteor/app/bigbluebutton/server/index.js b/apps/meteor/app/bigbluebutton/server/index.js deleted file mode 100644 index b6be696a20bd..000000000000 --- a/apps/meteor/app/bigbluebutton/server/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './bigbluebutton-api'; diff --git a/apps/meteor/app/custom-sounds/client/lib/CustomSounds.js b/apps/meteor/app/custom-sounds/client/lib/CustomSounds.js index 07c83303cb32..29a838d5bd3a 100644 --- a/apps/meteor/app/custom-sounds/client/lib/CustomSounds.js +++ b/apps/meteor/app/custom-sounds/client/lib/CustomSounds.js @@ -10,6 +10,7 @@ const getCustomSoundId = (sound) => `custom-sound-${sound}`; class CustomSoundsClass { constructor() { this.list = new ReactiveVar({}); + this.add({ _id: 'calling', name: 'Calling', extension: 'mp3', src: getURL('sounds/calling.mp3') }); this.add({ _id: 'chime', name: 'Chime', extension: 'mp3', src: getURL('sounds/chime.mp3') }); this.add({ _id: 'door', name: 'Door', extension: 'mp3', src: getURL('sounds/door.mp3') }); this.add({ _id: 'beep', name: 'Beep', extension: 'mp3', src: getURL('sounds/beep.mp3') }); @@ -108,6 +109,12 @@ class CustomSoundsClass { audio.currentTime = 0; } }; + + isPlaying = (sound) => { + const audio = document.querySelector(`#${getCustomSoundId(sound)}`); + + return audio && audio.duration > 0 && !audio.paused; + }; } export const CustomSounds = new CustomSoundsClass(); diff --git a/apps/meteor/app/lib/server/functions/createRoom.ts b/apps/meteor/app/lib/server/functions/createRoom.ts index 903d942dba19..db6b3eed7889 100644 --- a/apps/meteor/app/lib/server/functions/createRoom.ts +++ b/apps/meteor/app/lib/server/functions/createRoom.ts @@ -59,7 +59,7 @@ export const createRoom = function ( const now = new Date(); - const roomProps: Omit = { + const roomProps: Omit = { fname: name, ...extraData, name: getValidRoomName(name.trim(), undefined, { diff --git a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts index 7ba891c38a35..470b13b3d82e 100644 --- a/apps/meteor/app/lib/server/functions/saveUserIdentity.ts +++ b/apps/meteor/app/lib/server/functions/saveUserIdentity.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import type { IMessage } from '@rocket.chat/core-typings'; +import { VideoConference } from '@rocket.chat/models'; import { _setUsername } from './setUsername'; import { _setRealName } from './setRealName'; @@ -87,6 +88,9 @@ export function saveUserIdentity({ // update name and fname of group direct messages updateGroupDMsName(user); + + // update name and username of users on video conferences + Promise.await(VideoConference.updateUserReferences(user._id, username || previousUsername, name || previousName)); } } diff --git a/apps/meteor/app/livechat/client/index.js b/apps/meteor/app/livechat/client/index.js index 19a5118b809f..23da09e08a29 100644 --- a/apps/meteor/app/livechat/client/index.js +++ b/apps/meteor/app/livechat/client/index.js @@ -6,4 +6,3 @@ import './startup/notifyUnreadRooms'; import './views/app/dialog/closeRoom'; import './stylesheets/livechat.css'; import './externalFrame'; -import './lib/messageTypes'; diff --git a/apps/meteor/app/livechat/client/lib/messageTypes.js b/apps/meteor/app/livechat/client/lib/messageTypes.js deleted file mode 100644 index 9cf21362d9e1..000000000000 --- a/apps/meteor/app/livechat/client/lib/messageTypes.js +++ /dev/null @@ -1,5 +0,0 @@ -import { actionLinks } from '../../../action-links/client'; - -actionLinks.register('createLivechatCall', function (message, params, instance) { - instance.tabBar.open('video'); -}); diff --git a/apps/meteor/app/livechat/server/api/lib/livechat.js b/apps/meteor/app/livechat/server/api/lib/livechat.js index 92de8a4ffa9b..507f06d687ed 100644 --- a/apps/meteor/app/livechat/server/api/lib/livechat.js +++ b/apps/meteor/app/livechat/server/api/lib/livechat.js @@ -121,7 +121,7 @@ export async function settings({ businessUnit = '' } = {}) { nameFieldRegistrationForm: initSettings.Livechat_name_field_registration_form, emailFieldRegistrationForm: initSettings.Livechat_email_field_registration_form, displayOfflineForm: initSettings.Livechat_display_offline_form, - videoCall: initSettings.Omnichannel_call_provider === 'Jitsi' && initSettings.Jitsi_Enabled === true, + videoCall: initSettings.Omnichannel_call_provider === 'Jitsi', fileUpload: initSettings.Livechat_fileupload_enabled && initSettings.FileUpload_Enabled, language: initSettings.Language, transcript: initSettings.Livechat_enable_transcript, @@ -155,8 +155,8 @@ export async function settings({ businessUnit = '' } = {}) { }, ], jitsi: [ - { icon: 'icon-videocam', i18nLabel: 'Accept', method_id: 'createLivechatCall' }, - { icon: 'icon-cancel', i18nLabel: 'Decline', method_id: 'denyLivechatCall' }, + { icon: 'icon-videocam', i18nLabel: 'Accept' }, + { icon: 'icon-cancel', i18nLabel: 'Decline' }, ], }, }, diff --git a/apps/meteor/app/livechat/server/api/v1/videoCall.js b/apps/meteor/app/livechat/server/api/v1/videoCall.js index 60a26a7f4682..ca13d3278bcd 100644 --- a/apps/meteor/app/livechat/server/api/v1/videoCall.js +++ b/apps/meteor/app/livechat/server/api/v1/videoCall.js @@ -1,76 +1,17 @@ import { Meteor } from 'meteor/meteor'; import { Match, check } from 'meteor/check'; -import { Random } from 'meteor/random'; import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; -import { OmnichannelSourceType } from '@rocket.chat/core-typings'; import { Messages, Rooms, Settings } from '../../../../models'; import { settings as rcSettings } from '../../../../settings/server'; import { API } from '../../../../api/server'; -import { findGuest, getRoom, settings } from '../lib/livechat'; +import { settings } from '../lib/livechat'; import { hasPermission, canSendMessage } from '../../../../authorization'; import { Livechat } from '../../lib/Livechat'; import { Logger } from '../../../../logger'; const logger = new Logger('LivechatVideoCallApi'); -API.v1.addRoute('livechat/video.call/:token', { - async get() { - try { - check(this.urlParams, { - token: String, - }); - - check(this.queryParams, { - rid: Match.Maybe(String), - }); - - const { token } = this.urlParams; - - const guest = await findGuest(token); - if (!guest) { - throw new Meteor.Error('invalid-token'); - } - - const rid = this.queryParams.rid || Random.id(); - const roomInfo = { - jitsiTimeout: new Date(Date.now() + 3600 * 1000), - source: { - type: OmnichannelSourceType.API, - alias: 'video-call', - }, - }; - const { room } = await getRoom({ guest, rid, roomInfo }); - const config = await settings(); - if (!config.theme || !config.theme.actionLinks || !config.theme.actionLinks.jitsi) { - throw new Meteor.Error('invalid-livechat-config'); - } - - Messages.createWithTypeRoomIdMessageAndUser('livechat_video_call', room._id, '', guest, { - actionLinks: config.theme.actionLinks.jitsi, - }); - let rname; - if (rcSettings.get('Jitsi_URL_Room_Hash')) { - rname = rcSettings.get('uniqueID') + rid; - } else { - rname = encodeURIComponent(room.t === 'd' ? room.usernames.join(' x ') : room.name); - } - const videoCall = { - rid, - domain: rcSettings.get('Jitsi_Domain'), - provider: 'jitsi', - room: rcSettings.get('Jitsi_URL_Room_Prefix') + rname + rcSettings.get('Jitsi_URL_Room_Suffix'), - timeout: new Date(Date.now() + 3600 * 1000), - }; - - return API.v1.success(this.deprecationWarning({ videoCall })); - } catch (e) { - logger.error(e); - return API.v1.failure(e); - } - }, -}); - API.v1.addRoute( 'livechat/webrtc.call', { authRequired: true }, diff --git a/apps/meteor/app/livechat/server/index.js b/apps/meteor/app/livechat/server/index.js index e681d8010ae4..c44390cd6de6 100644 --- a/apps/meteor/app/livechat/server/index.js +++ b/apps/meteor/app/livechat/server/index.js @@ -59,7 +59,6 @@ import './methods/sendFileLivechatMessage'; import './methods/sendOfflineMessage'; import './methods/setCustomField'; import './methods/setDepartmentForVisitor'; -import './methods/startVideoCall'; import './methods/transfer'; import './methods/webhookTest'; import './methods/setUpConnection'; @@ -81,7 +80,6 @@ import './sendMessageBySMS'; import './api'; import './api/rest'; import './externalFrame'; -import './lib/messageTypes'; import './methods/saveBusinessHour'; export { Livechat } from './lib/Livechat'; diff --git a/apps/meteor/app/livechat/server/lib/Livechat.js b/apps/meteor/app/livechat/server/lib/Livechat.js index 424847dddb70..d365e7c95fd0 100644 --- a/apps/meteor/app/livechat/server/lib/Livechat.js +++ b/apps/meteor/app/livechat/server/lib/Livechat.js @@ -42,6 +42,7 @@ import { businessHourManager } from '../business-hour'; import notifications from '../../../notifications/server/lib/Notifications'; import { addUserRoles } from '../../../../server/lib/roles/addUserRoles'; import { removeUserFromRoles } from '../../../../server/lib/roles/removeUserFromRoles'; +import { VideoConf } from '../../../../server/sdk'; const logger = new Logger('Livechat'); @@ -553,7 +554,6 @@ export const Livechat = { 'Livechat_offline_form_unavailable', 'Livechat_display_offline_form', 'Omnichannel_call_provider', - 'Jitsi_Enabled', 'Language', 'Livechat_enable_transcript', 'Livechat_transcript_message', @@ -1413,6 +1413,10 @@ export const Livechat = { updateCallStatus(callId, rid, status, user) { Rooms.setCallStatus(rid, status); if (status === 'ended' || status === 'declined') { + if (Promise.await(VideoConf.declineLivechatCall(callId))) { + return; + } + return updateMessage({ _id: callId, msg: status, actionLinks: [], webRtcCallEndTs: new Date() }, user); } }, diff --git a/apps/meteor/app/livechat/server/lib/messageTypes.js b/apps/meteor/app/livechat/server/lib/messageTypes.js deleted file mode 100644 index dadb7e69594c..000000000000 --- a/apps/meteor/app/livechat/server/lib/messageTypes.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; - -import { actionLinks } from '../../../action-links/server'; -import { api } from '../../../../server/sdk/api'; -import { Messages, LivechatRooms } from '../../../models/server'; -import { settings } from '../../../settings/server'; -import { Livechat } from './Livechat'; - -actionLinks.register('denyLivechatCall', function (message /* , params*/) { - const user = Meteor.user(); - - Messages.createWithTypeRoomIdMessageAndUser('command', message.rid, 'endCall', user); - api.broadcast('notify.deleteMessage', message.rid, { _id: message._id }); - - const language = user.language || settings.get('Language') || 'en'; - - Livechat.closeRoom({ - user, - room: LivechatRooms.findOneById(message.rid), - comment: TAPi18n.__('Videocall_declined', { lng: language }), - }); - Meteor.defer(() => { - Messages.setHiddenById(message._id); - }); -}); diff --git a/apps/meteor/app/livechat/server/methods/startVideoCall.js b/apps/meteor/app/livechat/server/methods/startVideoCall.js deleted file mode 100644 index b50e24729cf9..000000000000 --- a/apps/meteor/app/livechat/server/methods/startVideoCall.js +++ /dev/null @@ -1,58 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Random } from 'meteor/random'; -import { OmnichannelSourceType } from '@rocket.chat/core-typings'; - -import { Messages } from '../../../models/server'; -import { settings } from '../../../settings/server'; -import { Livechat } from '../lib/Livechat'; -import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; - -Meteor.methods({ - async 'livechat:startVideoCall'(roomId) { - methodDeprecationLogger.warn('livechat:startVideoCall will be deprecated in future versions of Rocket.Chat'); - if (!Meteor.userId()) { - throw new Meteor.Error('error-not-authorized', 'Not authorized', { - method: 'livechat:closeByVisitor', - }); - } - - const guest = Meteor.user(); - - const message = { - _id: Random.id(), - rid: roomId || Random.id(), - msg: '', - ts: new Date(), - }; - - const roomInfo = { - jitsiTimeout: new Date(Date.now() + 3600 * 1000), - source: { - type: OmnichannelSourceType.API, - alias: 'video-call', - }, - }; - - const room = await Livechat.getRoom(guest, message, roomInfo); - message.rid = room._id; - - Messages.createWithTypeRoomIdMessageAndUser('livechat_video_call', room._id, '', guest, { - actionLinks: [ - { icon: 'icon-videocam', i18nLabel: 'Accept', method_id: 'createLivechatCall', params: '' }, - { icon: 'icon-cancel', i18nLabel: 'Decline', method_id: 'denyLivechatCall', params: '' }, - ], - }); - - let rname; - if (settings.get('Jitsi_URL_Room_Hash')) { - rname = settings.get('uniqueID') + roomId; - } else { - rname = encodeURIComponent(room.t === 'd' ? room.usernames.join(' x ') : room.name); - } - return { - roomId: room._id, - domain: settings.get('Jitsi_Domain'), - jitsiRoom: settings.get('Jitsi_URL_Room_Prefix') + rname + settings.get('Jitsi_URL_Room_Suffix'), - }; - }, -}); diff --git a/apps/meteor/app/models/server/models/Rooms.js b/apps/meteor/app/models/server/models/Rooms.js index 66f8e8366f03..e0c6df1e814b 100644 --- a/apps/meteor/app/models/server/models/Rooms.js +++ b/apps/meteor/app/models/server/models/Rooms.js @@ -51,20 +51,6 @@ export class Rooms extends Base { return this.findOne(query, options); } - setJitsiTimeout(_id, time) { - const query = { - _id, - }; - - const update = { - $set: { - jitsiTimeout: time, - }, - }; - - return this.update(query, update); - } - setCallStatus(_id, status) { const query = { _id, diff --git a/apps/meteor/app/settings/server/functions/validateSetting.ts b/apps/meteor/app/settings/server/functions/validateSetting.ts index 09db6d2cb74a..659bb12225c4 100644 --- a/apps/meteor/app/settings/server/functions/validateSetting.ts +++ b/apps/meteor/app/settings/server/functions/validateSetting.ts @@ -37,8 +37,9 @@ export const validateSetting = (_id: T['_id'], type: T['type } break; case 'select': + case 'lookup': if (typeof value !== 'string' && typeof value !== 'number') { - throw new Error(`Setting ${_id} is of type select but got ${typeof value}`); + throw new Error(`Setting ${_id} is of type ${type} but got ${typeof value}`); } break; case 'date': diff --git a/apps/meteor/app/statistics/server/lib/statistics.ts b/apps/meteor/app/statistics/server/lib/statistics.ts index 61efbfc4ceff..26cd858fd9ca 100644 --- a/apps/meteor/app/statistics/server/lib/statistics.ts +++ b/apps/meteor/app/statistics/server/lib/statistics.ts @@ -33,7 +33,7 @@ import { getAppsStatistics } from './getAppsStatistics'; import { getImporterStatistics } from './getImporterStatistics'; import { getServicesStatistics } from './getServicesStatistics'; import { getStatistics as getEnterpriseStatistics } from '../../../../ee/app/license/server'; -import { Analytics, Team } from '../../../../server/sdk'; +import { Analytics, Team, VideoConf } from '../../../../server/sdk'; import { getSettingsStatistics } from '../../../../server/lib/statistics/getSettingsStatistics'; const wizardFields = ['Organization_Type', 'Industry', 'Size', 'Country', 'Language', 'Server_Type', 'Register_Server']; @@ -400,6 +400,7 @@ export const statistics = { statistics.apps = getAppsStatistics(); statistics.services = getServicesStatistics(); statistics.importer = getImporterStatistics(); + statistics.videoConf = await VideoConf.getStatistics(); // If getSettingsStatistics() returns an error, save as empty object. statsPms.push( diff --git a/apps/meteor/app/ui-sidenav/client/roomList.js b/apps/meteor/app/ui-sidenav/client/roomList.js index e5d289134f63..422f4a2fdda7 100644 --- a/apps/meteor/app/ui-sidenav/client/roomList.js +++ b/apps/meteor/app/ui-sidenav/client/roomList.js @@ -135,9 +135,9 @@ const mergeSubRoom = (subscription) => { uids: 1, streamingOptions: 1, usernames: 1, + usersCount: 1, topic: 1, encrypted: 1, - jitsiTimeout: 1, // autoTranslate: 1, // autoTranslateLanguage: 1, description: 1, @@ -189,8 +189,7 @@ const mergeSubRoom = (subscription) => { teamMain, uids, usernames, - jitsiTimeout, - + usersCount, v, transcriptRequest, servedBy, @@ -228,8 +227,7 @@ const mergeSubRoom = (subscription) => { teamMain, uids, usernames, - jitsiTimeout, - + usersCount, v, transcriptRequest, servedBy, @@ -272,8 +270,7 @@ const mergeRoomSub = (room) => { teamMain, uids, usernames, - jitsiTimeout, - + usersCount, v, transcriptRequest, servedBy, @@ -310,6 +307,7 @@ const mergeRoomSub = (room) => { retention, uids, usernames, + usersCount, lastMessage, streamingOptions, teamId, @@ -327,7 +325,6 @@ const mergeRoomSub = (room) => { priorityId, livechatData, departmentId, - jitsiTimeout, ts, source, queuedAt, diff --git a/apps/meteor/app/videobridge/client/actionLink.js b/apps/meteor/app/videobridge/client/actionLink.js deleted file mode 100644 index 8825a64f109d..000000000000 --- a/apps/meteor/app/videobridge/client/actionLink.js +++ /dev/null @@ -1,68 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { Session } from 'meteor/session'; -import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; - -import { actionLinks } from '../../action-links/client'; -import { Rooms } from '../../models/client'; -import { dispatchToastMessage } from '../../../client/lib/toast'; -import { APIClient } from '../../utils/client'; - -actionLinks.register('joinJitsiCall', function (message, params, instance) { - const rid = Session.get('openedRoom'); - if (!rid) { - return; - } - - const room = Rooms.findOne({ _id: rid }); - const username = Meteor.user()?.username; - - if (!room) { - dispatchToastMessage({ type: 'info', message: TAPi18n.__('Call Already Ended', '') }); - return; - } - - if (room?.muted?.includes(username)) { - dispatchToastMessage({ type: 'error', message: TAPi18n.__('You_have_been_muted', '') }); - return; - } - - const clickTime = new Date(); - const jitsiTimeout = new Date(room.jitsiTimeout); - - APIClient.post('/v1/statistics.telemetry', { - params: [{ eventName: 'updateCounter', timestamp: Date.now(), settingsId: 'Jitsi_Click_To_Join_Count' }], - }); - - if (jitsiTimeout > clickTime) { - if (instance instanceof Function) { - instance('video'); - } else { - instance.tabBar.open('video'); - } - - return; - } - - // Get updated room info from the server to check if the call is still happening - Meteor.call('getRoomById', rid, (err, result) => { - if (err) { - throw err; - } - - // If the openedRoom has changed, abort - if (rid !== Session.get('openedRoom')) { - return; - } - - if (result?.jitsiTimeout && result.jitsiTimeout instanceof Date && result.jitsiTimeout > clickTime) { - if (instance instanceof Function) { - instance('video'); - } else { - instance.tabBar.open('video'); - } - return; - } - - dispatchToastMessage({ type: 'info', message: TAPi18n.__('Call Already Ended', '') }); - }); -}); diff --git a/apps/meteor/app/videobridge/client/index.js b/apps/meteor/app/videobridge/client/index.js deleted file mode 100644 index e1d57007880e..000000000000 --- a/apps/meteor/app/videobridge/client/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import './views/bbbLiveView.html'; -import './tabBar'; -import './actionLink'; -import '../lib/messageType'; diff --git a/apps/meteor/app/videobridge/client/index.ts b/apps/meteor/app/videobridge/client/index.ts new file mode 100644 index 000000000000..cd95da0a7d92 --- /dev/null +++ b/apps/meteor/app/videobridge/client/index.ts @@ -0,0 +1 @@ +import './tabBar'; diff --git a/apps/meteor/app/videobridge/client/tabBar.tsx b/apps/meteor/app/videobridge/client/tabBar.tsx index 06f85ca80614..8590bf74377e 100644 --- a/apps/meteor/app/videobridge/client/tabBar.tsx +++ b/apps/meteor/app/videobridge/client/tabBar.tsx @@ -1,119 +1,90 @@ -import React, { useMemo, lazy, ReactNode } from 'react'; -import { useStableArray } from '@rocket.chat/fuselage-hooks'; -import { Option, Badge } from '@rocket.chat/fuselage'; -import { useUser, useSetting, useTranslation } from '@rocket.chat/ui-contexts'; +import { useMemo, lazy } from 'react'; +import { useStableArray, useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import { useSetting, useUser } from '@rocket.chat/ui-contexts'; +import { useVideoConfDispatchOutgoing, useVideoConfIsCalling, useVideoConfIsRinging } from '../../../client/contexts/VideoConfContext'; import { addAction, ToolboxActionConfig } from '../../../client/views/room/lib/Toolbox'; -import Header from '../../../client/components/Header'; +import { VideoConfManager } from '../../../client/lib/VideoConfManager'; +import { useVideoConfWarning } from '../../../client/views/room/contextualBar/VideoConference/useVideoConfWarning'; +import { useHasLicenseModule } from '../../../ee/client/hooks/useHasLicenseModule'; -const templateBBB = lazy(() => import('../../../client/views/room/contextualBar/VideoConference/BBB')); - -addAction('bbb_video', ({ room }) => { - const enabled = useSetting('bigbluebutton_Enabled'); - const t = useTranslation(); - - const live = room?.streamingOptions && room.streamingOptions.type === 'call'; - - const enabledDirect = useSetting('bigbluebutton_enable_d'); - const enabledGroup = useSetting('bigbluebutton_enable_p'); - const enabledChannel = useSetting('bigbluebutton_enable_c'); - const enabledTeams = useSetting('bigbluebutton_enable_teams'); - - const groups = useStableArray( - [enabledDirect && 'direct', 'direct_multiple', enabledGroup && 'group', enabledTeams && 'team', enabledChannel && 'channel'].filter( - Boolean, - ) as ToolboxActionConfig['groups'], - ); - const user = useUser(); - const username = user ? user.username : ''; - const enableOption = enabled && (!username || !room.muted?.includes(username)); +addAction('calls', () => { + const hasLicense = useHasLicenseModule('videoconference-enterprise'); return useMemo( () => - enableOption + hasLicense ? { - groups, - id: 'bbb_video', - title: 'BBB_Video_Call', + groups: ['channel', 'group', 'team'], + id: 'calls', icon: 'phone', - template: templateBBB, - order: live ? -1 : 4, - renderAction: (props): ReactNode => ( - - {live ? ( - - ! - - ) : null} - - ), - renderOption: ({ label: { title, icon }, ...props }: any): ReactNode => ( - - ), + title: 'Calls', + template: lazy(() => import('../../../client/views/room/contextualBar/VideoConference/VideoConfList')), + order: 999, } : null, - [enableOption, groups, live, t], + [hasLicense], ); }); -const templateJitsi = lazy(() => import('../../../client/views/room/contextualBar/VideoConference/Jitsi')); +addAction('start-call', ({ room }) => { + const user = useUser(); + const dispatchWarning = useVideoConfWarning(); + const dispatchPopup = useVideoConfDispatchOutgoing(); + const isCalling = useVideoConfIsCalling(); + const isRinging = useVideoConfIsRinging(); -addAction('video', ({ room }) => { - const enabled = useSetting('Jitsi_Enabled'); - const t = useTranslation(); + const ownUser = room.uids && room.uids.length === 1; - const enabledChannel = useSetting('Jitsi_Enable_Channels'); - const enabledTeams = useSetting('Jitsi_Enable_Teams'); + // Only disable video conf if the settings are explicitly FALSE - any falsy value counts as true + const enabledDMs = useSetting('VideoConf_Enable_DMs') !== false; + const enabledChannel = useSetting('VideoConf_Enable_Channels') !== false; + const enabledTeams = useSetting('VideoConf_Enable_Teams') !== false; + const enabledGroups = useSetting('VideoConf_Enable_Groups') !== false; const enabledLiveChat = useSetting('Omnichannel_call_provider') === 'Jitsi'; + const live = room?.streamingOptions && room.streamingOptions.type === 'call'; + const enabled = enabledDMs || enabledChannel || enabledTeams || enabledGroups || enabledLiveChat; + + const enableOption = enabled && (!user?.username || !room.muted?.includes(user.username)); + const groups = useStableArray( - ['direct', 'direct_multiple', 'group', enabledLiveChat && 'live', enabledTeams && 'team', enabledChannel && 'channel'].filter( - Boolean, - ) as ToolboxActionConfig['groups'], + [ + enabledDMs && 'direct', + enabledDMs && 'direct_multiple', + enabledGroups && 'group', + enabledLiveChat && 'live', + enabledTeams && 'team', + enabledChannel && 'channel', + ].filter(Boolean) as ToolboxActionConfig['groups'], ); - const currentTime = new Date().getTime(); - const jitsiTimeout = new Date(room?.jitsiTimeout || currentTime).getTime(); - const live = jitsiTimeout > currentTime || null; - const user = useUser(); - const username = user ? user.username : ''; - const enableOption = enabled && (!username || !room.muted?.includes(username)); + const handleOpenVideoConf = useMutableCallback(async (): Promise => { + if (isCalling || isRinging) { + return; + } + + try { + await VideoConfManager.loadCapabilities(); + dispatchPopup({ rid: room._id }); + } catch (error) { + dispatchWarning(error.error); + } + }); return useMemo( () => - enableOption + enableOption && !ownUser ? { groups, - id: 'video', + id: 'start-call', title: 'Call', icon: 'phone', - template: templateJitsi, + action: handleOpenVideoConf, full: true, order: live ? -1 : 4, - renderAction: (props): ReactNode => ( - - {live && ( - - ! - - )} - - ), - renderOption: ({ label: { title, icon }, ...props }: any): ReactNode => ( - - ), } : null, - [enableOption, groups, live, t], + [groups, enableOption, live, handleOpenVideoConf, ownUser], ); }); diff --git a/apps/meteor/app/videobridge/client/views/bbbLiveView.html b/apps/meteor/app/videobridge/client/views/bbbLiveView.html deleted file mode 100644 index 47c0f4fcd0a3..000000000000 --- a/apps/meteor/app/videobridge/client/views/bbbLiveView.html +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/apps/meteor/app/videobridge/constants.js b/apps/meteor/app/videobridge/constants.js deleted file mode 100644 index 14cec201fbcb..000000000000 --- a/apps/meteor/app/videobridge/constants.js +++ /dev/null @@ -1,3 +0,0 @@ -export const TIMEOUT = 30 * 1000; -export const HEARTBEAT = TIMEOUT / 3; -export const DEBOUNCE = HEARTBEAT / 2; diff --git a/apps/meteor/app/videobridge/lib/messageType.js b/apps/meteor/app/videobridge/lib/messageType.js deleted file mode 100644 index 8f77cb61e9f6..000000000000 --- a/apps/meteor/app/videobridge/lib/messageType.js +++ /dev/null @@ -1,12 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; - -import { MessageTypes } from '../../ui-utils'; - -Meteor.startup(function () { - MessageTypes.registerType({ - id: 'jitsi_call_started', - system: true, - message: TAPi18n.__('Started_a_video_call'), - }); -}); diff --git a/apps/meteor/app/videobridge/server/actionLink.js b/apps/meteor/app/videobridge/server/actionLink.js deleted file mode 100644 index 4eedf13dbcc2..000000000000 --- a/apps/meteor/app/videobridge/server/actionLink.js +++ /dev/null @@ -1,3 +0,0 @@ -import { actionLinks } from '../../action-links/server'; - -actionLinks.register('joinJitsiCall', function (/* message, params*/) {}); diff --git a/apps/meteor/app/videobridge/server/index.js b/apps/meteor/app/videobridge/server/index.js deleted file mode 100644 index ee24f5157b78..000000000000 --- a/apps/meteor/app/videobridge/server/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import '../lib/messageType'; -import './settings'; -import './methods/jitsiSetTimeout'; -import './methods/jitsiGenerateToken'; -import './methods/bbb'; -import './actionLink'; diff --git a/apps/meteor/app/videobridge/server/methods/bbb.js b/apps/meteor/app/videobridge/server/methods/bbb.js deleted file mode 100644 index 3ec8cc0b03fb..000000000000 --- a/apps/meteor/app/videobridge/server/methods/bbb.js +++ /dev/null @@ -1,187 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { HTTP } from 'meteor/http'; -import { check } from 'meteor/check'; -import xml2js from 'xml2js'; - -import BigBlueButtonApi from '../../../bigbluebutton/server'; -import { SystemLogger } from '../../../../server/lib/logger/system'; -import { settings } from '../../../settings/server'; -import { Rooms, Users } from '../../../models/server'; -import { saveStreamingOptions } from '../../../channel-settings/server'; -import { canAccessRoom, canAccessRoomId } from '../../../authorization/server'; -import { API } from '../../../api/server'; - -const parser = new xml2js.Parser({ - explicitRoot: true, -}); - -const parseString = Meteor.wrapAsync(parser.parseString); - -const getBBBAPI = () => { - const url = settings.get('bigbluebutton_server'); - const secret = settings.get('bigbluebutton_sharedSecret'); - const api = new BigBlueButtonApi(`${url}/bigbluebutton/api`, secret); - return { api, url }; -}; - -Meteor.methods({ - bbbJoin({ rid }) { - check(rid, String); - - if (!this.userId) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'bbbJoin' }); - } - - if (!rid) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'bbbJoin' }); - } - - const user = Users.findOneById(this.userId); - if (!user) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'bbbJoin' }); - } - - const room = Rooms.findOneById(rid); - if (!room) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'bbbJoin' }); - } - - if (!canAccessRoom(room, user)) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'bbbJoin' }); - } - - if (!settings.get('bigbluebutton_Enabled')) { - throw new Meteor.Error('error-not-allowed', 'Not Allowed', { method: 'bbbJoin' }); - } - - const { api } = getBBBAPI(); - const meetingID = settings.get('uniqueID') + rid; - const createUrl = api.urlFor('create', { - name: room.t === 'd' ? 'Direct' : room.name, - meetingID, - attendeePW: 'ap', - moderatorPW: 'mp', - welcome: '
Welcome to %%CONFNAME%%!', - meta_html5chat: false, - meta_html5navbar: false, - meta_html5autoswaplayout: true, - meta_html5autosharewebcam: false, - meta_html5hidepresentation: true, - }); - - const createResult = HTTP.get(createUrl); - const doc = parseString(createResult.content); - - if (doc.response.returncode[0]) { - const hookApi = api.urlFor('hooks/create', { - meetingID, - callbackURL: Meteor.absoluteUrl(`api/v1/videoconference.bbb.update/${meetingID}`), - }); - - const hookResult = HTTP.get(hookApi); - - if (hookResult.statusCode !== 200) { - // TODO improve error logging - SystemLogger.error(hookResult); - return; - } - - saveStreamingOptions(rid, { - type: 'call', - }); - - return { - url: api.urlFor('join', { - password: 'mp', // mp if moderator ap if attendee - meetingID, - fullName: user.username, - userID: user._id, - joinViaHtml5: true, - avatarURL: Meteor.absoluteUrl(`avatar/${user.username}`), - // clientURL: `${ url }/html5client/join`, - }), - }; - } - }, - - bbbEnd({ rid }) { - check(rid, String); - - if (!this.userId) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'bbbEnd' }); - } - - if (!rid) { - throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'bbbEnd' }); - } - - if (!canAccessRoomId(rid, this.userId)) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'bbbEnd' }); - } - - if (!settings.get('bigbluebutton_Enabled')) { - throw new Meteor.Error('error-not-allowed', 'Not Allowed', { method: 'bbbEnd' }); - } - - const { api } = getBBBAPI(); - const meetingID = settings.get('uniqueID') + rid; - const endApi = api.urlFor('end', { - meetingID, - password: 'mp', // mp if moderator ap if attendee - }); - - const endApiResult = HTTP.get(endApi); - - if (endApiResult.statusCode !== 200) { - saveStreamingOptions(rid, {}); - throw new Meteor.Error(endApiResult); - } - const doc = parseString(endApiResult.content); - - if (['SUCCESS', 'FAILED'].includes(doc.response.returncode[0])) { - saveStreamingOptions(rid, {}); - } - }, -}); - -API.v1.addRoute( - 'videoconference.bbb.update/:id', - { authRequired: false }, - { - post() { - // TODO check checksum - const event = JSON.parse(this.bodyParams.event)[0]; - const eventType = event.data.id; - const meetingID = event.data.attributes.meeting['external-meeting-id']; - const rid = meetingID.replace(settings.get('uniqueID'), ''); - - SystemLogger.debug(eventType, rid); - - if (eventType === 'meeting-ended') { - saveStreamingOptions(rid, {}); - } - - // if (eventType === 'user-left') { - // const { api } = getBBBAPI(); - - // const getMeetingInfoApi = api.urlFor('getMeetingInfo', { - // meetingID - // }); - - // const getMeetingInfoResult = HTTP.get(getMeetingInfoApi); - - // if (getMeetingInfoResult.statusCode !== 200) { - // // TODO improve error logging - // SystemLogger.error({ getMeetingInfoResult }); - // } - - // const doc = parseString(getMeetingInfoResult.content); - - // if (doc.response.returncode[0]) { - // const participantCount = parseInt(doc.response.participantCount[0]); - // SystemLogger.debug(participantCount); - // } - // } - }, - }, -); diff --git a/apps/meteor/app/videobridge/server/methods/jitsiGenerateToken.js b/apps/meteor/app/videobridge/server/methods/jitsiGenerateToken.js deleted file mode 100644 index 2aabfe83dffc..000000000000 --- a/apps/meteor/app/videobridge/server/methods/jitsiGenerateToken.js +++ /dev/null @@ -1,79 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { jws } from 'jsrsasign'; - -import { Rooms } from '../../../models/server'; -import { settings } from '../../../settings/server'; -import { canAccessRoom } from '../../../authorization/server/functions/canAccessRoom'; -import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress'; - -Meteor.methods({ - 'jitsi:generateAccessToken': (rid) => { - if (!Meteor.userId()) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: 'jitsi:generateToken', - }); - } - - const room = Rooms.findOneById(rid); - - if (!canAccessRoom(room, Meteor.user())) { - throw new Meteor.Error('error-not-allowed', 'not allowed', { method: 'jitsi:generateToken' }); - } - - let rname; - if (settings.get('Jitsi_URL_Room_Hash')) { - rname = settings.get('uniqueID') + rid; - } else { - rname = encodeURIComponent(room.t === 'd' ? room.usernames.join(' x ') : room.name); - } - const jitsiRoom = settings.get('Jitsi_URL_Room_Prefix') + rname + settings.get('Jitsi_URL_Room_Suffix'); - const jitsiDomain = settings.get('Jitsi_Domain'); - const jitsiApplicationId = settings.get('Jitsi_Application_ID'); - const jitsiApplicationSecret = settings.get('Jitsi_Application_Secret'); - const jitsiLimitTokenToRoom = settings.get('Jitsi_Limit_Token_To_Room'); - - function addUserContextToPayload(payload) { - const user = Meteor.user(); - payload.context = { - user: { - name: user.name, - email: getUserEmailAddress(user), - avatar: Meteor.absoluteUrl(`avatar/${user.username}`), - id: user._id, - }, - }; - - return payload; - } - - const JITSI_OPTIONS = { - jitsi_domain: jitsiDomain, - jitsi_lifetime_token: '1hour', // only 1 hour (for security reasons) - jitsi_application_id: jitsiApplicationId, - jitsi_application_secret: jitsiApplicationSecret, - }; - - const HEADER = { - typ: 'JWT', - alg: 'HS256', - }; - - const commonPayload = { - iss: JITSI_OPTIONS.jitsi_application_id, - sub: JITSI_OPTIONS.jitsi_domain, - iat: jws.IntDate.get('now'), - nbf: jws.IntDate.get('now'), - exp: jws.IntDate.get(`now + ${JITSI_OPTIONS.jitsi_lifetime_token}`), - aud: 'RocketChat', - room: jitsiLimitTokenToRoom ? jitsiRoom : '*', - context: '', // first empty - }; - - const header = JSON.stringify(HEADER); - const payload = JSON.stringify(addUserContextToPayload(commonPayload)); - - return jws.JWS.sign(HEADER.alg, header, payload, { - rstr: JITSI_OPTIONS.jitsi_application_secret, - }); - }, -}); diff --git a/apps/meteor/app/videobridge/server/methods/jitsiSetTimeout.js b/apps/meteor/app/videobridge/server/methods/jitsiSetTimeout.js deleted file mode 100644 index acc51845ac79..000000000000 --- a/apps/meteor/app/videobridge/server/methods/jitsiSetTimeout.js +++ /dev/null @@ -1,86 +0,0 @@ -import { Meteor } from 'meteor/meteor'; -import { TAPi18n } from 'meteor/rocketchat:tap-i18n'; - -import { Rooms, Messages, Users } from '../../../models/server'; -import { callbacks } from '../../../../lib/callbacks'; -import { metrics } from '../../../metrics/server'; -import * as CONSTANTS from '../../constants'; -import { canSendMessage } from '../../../authorization/server'; -import { SystemLogger } from '../../../../server/lib/logger/system'; -import { settings } from '../../../settings/server'; - -// TODO: Access Token missing. This is just a partial solution, it doesn't handle access token generation logic as present in this file - client/views/room/contextualBar/Call/Jitsi/CallJitsWithData.js -const resolveJitsiCallUrl = (room) => { - const rname = settings.get('Jitsi_URL_Room_Hash') - ? settings.get('uniqueID') + room._id - : encodeURIComponent(room.t === 'd' ? room.usernames.join(' x ') : room.name); - return `${settings.get('Jitsi_SSL') ? 'https://' : 'http://'}${settings.get('Jitsi_Domain')}/${settings.get( - 'Jitsi_URL_Room_Prefix', - )}${rname}${settings.get('Jitsi_URL_Room_Suffix')}`; -}; - -Meteor.methods({ - 'jitsi:updateTimeout': (rid, joiningNow = true) => { - if (!Meteor.userId()) { - throw new Meteor.Error('error-invalid-user', 'Invalid user', { - method: 'jitsi:updateTimeout', - }); - } - - const uid = Meteor.userId(); - - const user = Users.findOneById(uid, { - fields: { - username: 1, - type: 1, - }, - }); - - try { - const room = canSendMessage(rid, { uid, username: user.username, type: user.type }); - - const currentTime = new Date().getTime(); - - const jitsiTimeout = room.jitsiTimeout && new Date(room.jitsiTimeout).getTime(); - - const nextTimeOut = new Date(currentTime + CONSTANTS.TIMEOUT); - - if (!jitsiTimeout || currentTime > jitsiTimeout - CONSTANTS.TIMEOUT / 2) { - Rooms.setJitsiTimeout(rid, nextTimeOut); - } - - if (joiningNow && (!jitsiTimeout || currentTime > jitsiTimeout)) { - metrics.messagesSent.inc(); // TODO This line needs to be moved to it's proper place. See the comments on: https://github.com/RocketChat/Rocket.Chat/pull/5736 - - const message = Messages.createWithTypeRoomIdMessageAndUser('jitsi_call_started', rid, '', Meteor.user(), { - actionLinks: [ - { - icon: 'icon-videocam', - label: TAPi18n.__('Click_to_join'), - i18nLabel: 'Click_to_join', - method_id: 'joinJitsiCall', - params: '', - }, - ], - customFields: { - ...(room.customFields && { ...room.customFields }), - ...(room.t === 'l' && { jitsiCallUrl: resolveJitsiCallUrl(room) }), // Note: this is just a temporary solution for the jitsi calls to work in Livechat. In future we wish to create specific events for specific to livechat calls (eg: start, accept, decline, end, etc) and this url info will be passed via there - }, - }); - message.msg = TAPi18n.__('Started_a_video_call'); - callbacks.run('afterSaveMessage', message, { - ...room, - jitsiTimeout: currentTime + CONSTANTS.TIMEOUT, - }); - } - - return jitsiTimeout || nextTimeOut; - } catch (err) { - SystemLogger.error({ msg: 'Error starting video call:', err }); - - throw new Meteor.Error('error-starting-video-call', err.message, { - method: 'jitsi:updateTimeout', - }); - } - }, -}); diff --git a/apps/meteor/app/videobridge/server/settings.ts b/apps/meteor/app/videobridge/server/settings.ts deleted file mode 100644 index b8ac7f97352a..000000000000 --- a/apps/meteor/app/videobridge/server/settings.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { settingsRegistry } from '../../settings/server'; - -settingsRegistry.addGroup('Video Conference', function () { - this.section('BigBlueButton', function () { - this.add('bigbluebutton_Enabled', false, { - type: 'boolean', - i18nLabel: 'Enabled', - alert: 'This Feature is currently in beta! Please report bugs to github.com/RocketChat/Rocket.Chat/issues', - public: true, - }); - - this.add('bigbluebutton_server', '', { - type: 'string', - i18nLabel: 'Domain', - enableQuery: { - _id: 'bigbluebutton_Enabled', - value: true, - }, - }); - - this.add('bigbluebutton_sharedSecret', '', { - type: 'string', - i18nLabel: 'Shared_Secret', - enableQuery: { - _id: 'bigbluebutton_Enabled', - value: true, - }, - }); - - this.add('bigbluebutton_Open_New_Window', false, { - type: 'boolean', - enableQuery: { - _id: 'bigbluebutton_Enabled', - value: true, - }, - i18nLabel: 'Always_open_in_new_window', - public: true, - }); - - this.add('bigbluebutton_enable_d', true, { - type: 'boolean', - i18nLabel: 'WebRTC_Enable_Direct', - enableQuery: { - _id: 'bigbluebutton_Enabled', - value: true, - }, - public: true, - }); - - this.add('bigbluebutton_enable_p', true, { - type: 'boolean', - i18nLabel: 'WebRTC_Enable_Private', - enableQuery: { - _id: 'bigbluebutton_Enabled', - value: true, - }, - public: true, - }); - - this.add('bigbluebutton_enable_c', false, { - type: 'boolean', - i18nLabel: 'WebRTC_Enable_Channel', - enableQuery: { - _id: 'bigbluebutton_Enabled', - value: true, - }, - public: true, - }); - - this.add('bigbluebutton_enable_teams', false, { - type: 'boolean', - i18nLabel: 'BBB_Enable_Teams', - enableQuery: { - _id: 'bigbluebutton_Enabled', - value: true, - }, - public: true, - }); - }); - - this.section('Jitsi', function () { - this.add('Jitsi_Enabled', false, { - type: 'boolean', - i18nLabel: 'Enabled', - alert: 'This Feature is currently in beta! Please report bugs to github.com/RocketChat/Rocket.Chat/issues', - public: true, - }); - - this.add('Jitsi_Domain', 'meet.jit.si', { - type: 'string', - enableQuery: { - _id: 'Jitsi_Enabled', - value: true, - }, - i18nLabel: 'Domain', - public: true, - }); - - this.add('Jitsi_URL_Room_Prefix', 'RocketChat', { - type: 'string', - enableQuery: { - _id: 'Jitsi_Enabled', - value: true, - }, - i18nLabel: 'URL_room_prefix', - public: true, - }); - - this.add('Jitsi_URL_Room_Suffix', '', { - type: 'string', - enableQuery: { - _id: 'Jitsi_Enabled', - value: true, - }, - i18nLabel: 'URL_room_suffix', - public: true, - }); - - this.add('Jitsi_URL_Room_Hash', true, { - type: 'boolean', - enableQuery: { - _id: 'Jitsi_Enabled', - value: true, - }, - i18nLabel: 'URL_room_hash', - i18nDescription: 'URL_room_hash_description', - public: true, - }); - - this.add('Jitsi_SSL', true, { - type: 'boolean', - enableQuery: { - _id: 'Jitsi_Enabled', - value: true, - }, - i18nLabel: 'SSL', - public: true, - }); - - this.add('Jitsi_Open_New_Window', true, { - type: 'boolean', - enableQuery: { - _id: 'Jitsi_Enabled', - value: true, - }, - i18nLabel: 'Always_open_in_new_window', - public: true, - }); - - this.add('Jitsi_Enable_Channels', false, { - type: 'boolean', - enableQuery: { - _id: 'Jitsi_Enabled', - value: true, - }, - i18nLabel: 'Jitsi_Enable_Channels', - public: true, - }); - - this.add('Jitsi_Enable_Teams', false, { - type: 'boolean', - enableQuery: { - _id: 'Jitsi_Enabled', - value: true, - }, - i18nLabel: 'Jitsi_Enable_Teams', - public: true, - }); - - this.add('Jitsi_Chrome_Extension', 'nocfbnnmjnndkbipkabodnheejiegccf', { - type: 'string', - enableQuery: { - _id: 'Jitsi_Enabled', - value: true, - }, - i18nLabel: 'Jitsi_Chrome_Extension', - public: true, - }); - - this.add('Jitsi_Enabled_TokenAuth', false, { - type: 'boolean', - enableQuery: { - _id: 'Jitsi_Enabled', - value: true, - }, - i18nLabel: 'Jitsi_Enabled_TokenAuth', - public: true, - }); - - this.add('Jitsi_Application_ID', '', { - type: 'string', - enableQuery: [ - { _id: 'Jitsi_Enabled', value: true }, - { _id: 'Jitsi_Enabled_TokenAuth', value: true }, - ], - i18nLabel: 'Jitsi_Application_ID', - }); - - this.add('Jitsi_Application_Secret', '', { - type: 'string', - enableQuery: [ - { _id: 'Jitsi_Enabled', value: true }, - { _id: 'Jitsi_Enabled_TokenAuth', value: true }, - ], - i18nLabel: 'Jitsi_Application_Secret', - }); - - this.add('Jitsi_Limit_Token_To_Room', true, { - type: 'boolean', - enableQuery: [ - { _id: 'Jitsi_Enabled', value: true }, - { _id: 'Jitsi_Enabled_TokenAuth', value: true }, - ], - i18nLabel: 'Jitsi_Limit_Token_To_Room', - public: true, - }); - this.add('Jitsi_Click_To_Join_Count', 0, { - type: 'int', - hidden: true, - }); - this.add('Jitsi_Start_SlashCommands_Count', 0, { - type: 'int', - hidden: true, - }); - }); -}); diff --git a/apps/meteor/client/components/GenericModal.tsx b/apps/meteor/client/components/GenericModal.tsx index 07c2b35c21cb..94b6bf960aa0 100644 --- a/apps/meteor/client/components/GenericModal.tsx +++ b/apps/meteor/client/components/GenericModal.tsx @@ -9,8 +9,8 @@ type VariantType = 'danger' | 'warning' | 'info' | 'success'; type GenericModalProps = RequiredModalProps & { variant?: VariantType; children?: ReactNode; - cancelText?: string; - confirmText?: string; + cancelText?: ReactNode; + confirmText?: ReactNode; title?: string | ReactElement; icon?: ComponentProps['name'] | ReactElement | null; confirmDisabled?: boolean; diff --git a/apps/meteor/client/components/RoomIcon/RoomIcon.tsx b/apps/meteor/client/components/RoomIcon/RoomIcon.tsx index 22bcd3cde2d3..2d81558b285d 100644 --- a/apps/meteor/client/components/RoomIcon/RoomIcon.tsx +++ b/apps/meteor/client/components/RoomIcon/RoomIcon.tsx @@ -8,12 +8,18 @@ import { OmnichannelRoomIcon } from './OmnichannelRoomIcon'; export const RoomIcon = ({ room, size = 'x16', + isIncomingCall, placement, }: { room: IRoom; size?: ComponentProps['size']; + isIncomingCall?: boolean; placement: 'sidebar' | 'default'; }): ReactElement | null => { + if (isIncomingCall) { + return ; + } + if (room.prid) { return ; } diff --git a/apps/meteor/client/components/modal/ModalPortal.tsx b/apps/meteor/client/components/modal/ModalPortal.tsx index 7b5152ae6e4e..bb3cf00dc892 100644 --- a/apps/meteor/client/components/modal/ModalPortal.tsx +++ b/apps/meteor/client/components/modal/ModalPortal.tsx @@ -8,6 +8,9 @@ type ModalPortalProps = { children?: ReactNode; }; +/** + * @todo: move to portals folder + */ const ModalPortal = ({ children }: ModalPortalProps): ReactElement => { const [modalRoot] = useState(() => createAnchor('modal-root')); useEffect(() => (): void => deleteAnchor(modalRoot), [modalRoot]); diff --git a/apps/meteor/client/contexts/VideoConfContext.ts b/apps/meteor/client/contexts/VideoConfContext.ts new file mode 100644 index 000000000000..3f71fc7b05f4 --- /dev/null +++ b/apps/meteor/client/contexts/VideoConfContext.ts @@ -0,0 +1,74 @@ +import { IRoom } from '@rocket.chat/core-typings'; +import { createContext, useContext } from 'react'; +import { Subscription, useSubscription } from 'use-subscription'; + +import { DirectCallParams, ProviderCapabilities, CallPreferences } from '../lib/VideoConfManager'; + +export type VideoConfPopupPayload = { + id: string; + rid: IRoom['_id']; + isReceiving?: boolean; +}; + +export type VideoConfIncomingCall = { + callId: string; + uid: string; + rid: string; +}; + +type VideoConfContextValue = { + dispatchOutgoing: (options: Omit) => void; + dismissOutgoing: () => void; + startCall: (rid: IRoom['_id'], title?: string) => void; + acceptCall: (callId: string) => void; + joinCall: (callId: string) => void; + dismissCall: (callId: string) => void; + rejectIncomingCall: (callId: string) => void; + abortCall: () => void; + setPreferences: (prefs: { mic?: boolean; cam?: boolean }) => void; + queryIncomingCalls: Subscription; + queryRinging: Subscription; + queryCalling: Subscription; + queryCapabilities: Subscription; + queryPreferences: Subscription; +}; + +export const VideoConfContext = createContext(undefined); +const useVideoConfContext = (): VideoConfContextValue => { + const context = useContext(VideoConfContext); + if (!context) { + throw new Error('Must be running in VideoConf Context'); + } + + return context; +}; + +export const useVideoConfDispatchOutgoing = (): VideoConfContextValue['dispatchOutgoing'] => useVideoConfContext().dispatchOutgoing; +export const useVideoConfDismissOutgoing = (): VideoConfContextValue['dismissOutgoing'] => useVideoConfContext().dismissOutgoing; +export const useVideoConfStartCall = (): VideoConfContextValue['startCall'] => useVideoConfContext().startCall; +export const useVideoConfAcceptCall = (): VideoConfContextValue['acceptCall'] => useVideoConfContext().acceptCall; +export const useVideoConfJoinCall = (): VideoConfContextValue['joinCall'] => useVideoConfContext().joinCall; +export const useVideoConfDismissCall = (): VideoConfContextValue['dismissCall'] => useVideoConfContext().dismissCall; +export const useVideoConfAbortCall = (): VideoConfContextValue['abortCall'] => useVideoConfContext().abortCall; +export const useVideoConfRejectIncomingCall = (): VideoConfContextValue['rejectIncomingCall'] => useVideoConfContext().rejectIncomingCall; +export const useVideoConfIncomingCalls = (): DirectCallParams[] => { + const { queryIncomingCalls } = useVideoConfContext(); + return useSubscription(queryIncomingCalls); +}; +export const useVideoConfSetPreferences = (): VideoConfContextValue['setPreferences'] => useVideoConfContext().setPreferences; +export const useVideoConfIsRinging = (): boolean => { + const { queryRinging } = useVideoConfContext(); + return useSubscription(queryRinging); +}; +export const useVideoConfIsCalling = (): boolean => { + const { queryCalling } = useVideoConfContext(); + return useSubscription(queryCalling); +}; +export const useVideoConfCapabilities = (): ProviderCapabilities => { + const { queryCapabilities } = useVideoConfContext(); + return useSubscription(queryCapabilities); +}; +export const useVideoConfPreferences = (): CallPreferences => { + const { queryPreferences } = useVideoConfContext(); + return useSubscription(queryPreferences); +}; diff --git a/apps/meteor/client/lib/VideoConfManager.ts b/apps/meteor/client/lib/VideoConfManager.ts new file mode 100644 index 000000000000..c994660fb4b9 --- /dev/null +++ b/apps/meteor/client/lib/VideoConfManager.ts @@ -0,0 +1,680 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Emitter } from '@rocket.chat/emitter'; +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; + +import { Notifications } from '../../app/notifications/client'; +import { APIClient } from '../../app/utils/client'; +import { getConfig } from './utils/getConfig'; + +const debug = !!(getConfig('debug') || getConfig('debug-VideoConf')); + +// The interval between attempts to call the remote user +const CALL_INTERVAL = 3000; +// How many attempts to call we're gonna make +const CALL_ATTEMPT_LIMIT = 10; +// The amount of time we'll assume an incoming call is still valid without any updates from the remote user +const CALL_TIMEOUT = 10000; +// How long are we gonna wait for a link after accepting an incoming call +const ACCEPT_TIMEOUT = 5000; + +export type DirectCallParams = { + uid: IUser['_id']; + rid: IRoom['_id']; + callId: string; + dismissed?: boolean; + // TODO: improve this, nowadays there is not possible check if the video call has finished, but ist a nice improvement + // state: 'incoming' | 'outgoing' | 'connected' | 'disconnected' | 'dismissed'; +}; + +type IncomingDirectCall = DirectCallParams & { timeout: number }; + +export type CallPreferences = { + mic?: boolean; + cam?: boolean; +}; + +export type ProviderCapabilities = { + mic?: boolean; + cam?: boolean; + title?: boolean; +}; + +export type CurrentCallParams = { + callId: string; + url: string; +}; + +type VideoConfEvents = { + // We gave up on calling a remote user or they rejected our call + 'direct/cancel': DirectCallParams; + + // A remote user is calling us + 'direct/ringing': DirectCallParams; + + // An incoming call was lost, either by timeout or because the remote user canceled + 'direct/lost': DirectCallParams; + + // We tried to accept an incoming call but the process failed + 'direct/failed': DirectCallParams; + + // A remote user accepted our call + 'direct/accepted': DirectCallParams; + + // We stopped calling a remote user + 'direct/stopped': DirectCallParams; + + 'preference/changed': { key: keyof CallPreferences; value: boolean }; + + // The list of incoming calls has changed in some way + 'incoming/changed': void; + + // The list of ringing incoming calls may have changed + 'ringing/changed': void; + + // The value of `isCalling` may have changed + 'calling/changed': void; + + 'calling/ended': void; + + // When join call + 'call/join': CurrentCallParams; + + 'join/error': { error: string }; + + 'start/error': { error: string }; + + 'capabilities/changed': void; +}; +export const VideoConfManager = new (class VideoConfManager extends Emitter { + private userId: string | undefined; + + private currentCallHandler: ReturnType | undefined; + + private currentCallData: (DirectCallParams & { joined?: boolean }) | undefined; + + private startingNewCall = false; + + private hooks: (() => void)[] = []; + + private incomingDirectCalls: Map; + + private acceptingCallId: string | undefined; + + private acceptingCallTimeout = 0; + + private _preferences: CallPreferences; + + private _capabilities: ProviderCapabilities; + + public get preferences(): CallPreferences { + return this._preferences; + } + + public get capabilities(): ProviderCapabilities { + return this._capabilities; + } + + constructor() { + super(); + this.incomingDirectCalls = new Map(); + this._preferences = { mic: true }; + this._capabilities = {}; + } + + public isBusy(): boolean { + if (this.startingNewCall) { + return true; + } + + return this.isCalling(); + } + + public isRinging(): boolean { + return ![...this.incomingDirectCalls.values()].every(({ dismissed }) => Boolean(dismissed)); + } + + public isCalling(): boolean { + if (this.currentCallHandler || (this.currentCallData && !this.currentCallData.joined)) { + return true; + } + + return false; + } + + public getIncomingDirectCalls(): DirectCallParams[] { + return [...this.incomingDirectCalls.values()].map(({ timeout: _, ...call }) => ({ ...call })); + } + + public async startCall(roomId: IRoom['_id'], title?: string): Promise { + if (!this.userId || this.isBusy()) { + throw new Error('Video manager is busy.'); + } + + debug && console.log(`[VideoConf] Starting new call on room ${roomId}`); + this.startingNewCall = true; + this.emit('calling/changed'); + + const { data } = await APIClient.post('/v1/video-conference.start', { roomId, title, allowRinging: true }).catch((e: any) => { + debug && console.error(`[VideoConf] Failed to start new call on room ${roomId}`); + this.startingNewCall = false; + this.emit('calling/changed'); + this.emit('start/error', { error: e?.xhr?.responseJSON?.error || 'unknown-error' }); + + return Promise.reject(e); + }); + + this.startingNewCall = false; + this.emit('calling/changed'); + + if (data.type !== 'direct') { + this.emit('calling/ended'); + } + + switch (data.type) { + case 'direct': + return this.callUser({ uid: data.callee, rid: roomId, callId: data.callId }); + case 'videoconference': + return this.joinCall(data.callId); + case 'livechat': + return this.joinCall(data.callId); + } + } + + public acceptIncomingCall(callId: string): void { + const callData = this.incomingDirectCalls.get(callId); + if (!callData) { + throw new Error('Unable to find accepted call information.'); + } + + debug && console.log(`[VideoConf] Accepting incoming call.`); + + if (callData.timeout) { + clearTimeout(callData.timeout); + } + + // Mute this call Id so any lingering notifications don't trigger it again + this.dismissIncomingCall(callId); + + this.acceptingCallId = callId; + this.acceptingCallTimeout = setTimeout(() => { + if (this.acceptingCallId !== callId) { + debug && console.warn(`[VideoConf] Accepting call timeout not properly cleared.`); + return; + } + + debug && console.log(`[VideoConf] Attempt to accept call has timed out.`); + this.acceptingCallId = undefined; + this.acceptingCallTimeout = 0; + + this.removeIncomingCall(callId); + + this.emit('direct/failed', { callId, uid: callData.uid, rid: callData.rid }); + }, ACCEPT_TIMEOUT) as unknown as number; + + debug && console.log(`[VideoConf] Notifying user ${callData.uid} that we accept their call.`); + Notifications.notifyUser(callData.uid, 'video-conference.accepted', { callId, uid: this.userId, rid: callData.rid }); + } + + public rejectIncomingCall(callId: string): void { + this.dismissIncomingCall(callId); + + const callData = this.incomingDirectCalls.get(callId); + if (!callData) { + return; + } + + Notifications.notifyUser(callData.uid, 'video-conference.rejected', { callId, uid: this.userId, rid: callData.rid }); + this.loseIncomingCall(callId); + } + + public dismissedIncomingCalls(): void { + // Mute all calls that are currently ringing + if ([...this.incomingDirectCalls.keys()].some((callId) => this.dismissedIncomingCallHelper(callId))) { + this.emit('ringing/changed'); + this.emit('incoming/changed'); + } + } + + public async loadCapabilities(): Promise { + const { capabilities } = await APIClient.get('/v1/video-conference.capabilities').catch((e: any) => { + debug && console.error(`[VideoConf] Failed to load video conference capabilities`); + + return Promise.reject(e); + }); + + this._capabilities = capabilities || {}; + this.emit('capabilities/changed'); + } + + private dismissedIncomingCallHelper(callId: string): boolean { + // Muting will stop a callId from ringing, but it doesn't affect any part of the existing workflow + const callData = this.incomingDirectCalls.get(callId); + if (!callData) { + return false; + } + this.incomingDirectCalls.set(callId, { ...callData, dismissed: true }); + setTimeout(() => { + const callData = this.incomingDirectCalls.get(callId); + + if (!callData) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { dismissed, ...rest } = callData; + this.incomingDirectCalls.set(callId, { ...rest }); + }, CALL_TIMEOUT * 20); + return true; + } + + public dismissIncomingCall(callId: string): boolean { + if (this.dismissedIncomingCallHelper(callId)) { + debug && console.log(`[VideoConf] Dismissed call ${callId}`); + this.emit('ringing/changed'); + this.emit('incoming/changed'); + return true; + } + debug && console.log(`[VideoConf] Failed to dismiss call ${callId}`); + return false; + } + + public updateUser(): void { + const userId = Meteor.userId(); + + if (this.userId === userId) { + debug && console.log(`[VideoConf] Logged user has not changed, so we're not changing the hooks.`); + return; + } + + debug && console.log(`[VideoConf] Logged user has changed.`); + + if (this.userId) { + this.disconnect(); + } + + if (userId) { + this.connectUser(userId); + } + } + + public changePreference(key: keyof CallPreferences, value: boolean): void { + this._preferences[key] = value; + this.emit('preference/changed', { key, value }); + } + + public setPreferences(prefs: Partial): void { + for (const key in prefs) { + if (prefs.hasOwnProperty(key)) { + const prefKey = key as keyof CallPreferences; + this.changePreference(prefKey, prefs[prefKey] as boolean); + } + } + } + + public async joinCall(callId: string): Promise { + debug && console.log(`[VideoConf] Joining call ${callId}.`); + + if (this.acceptingCallTimeout && this.acceptingCallId === callId) { + clearTimeout(this.acceptingCallTimeout); + this.acceptingCallTimeout = 0; + this.acceptingCallId = undefined; + } + + if (this.incomingDirectCalls.has(callId)) { + this.removeIncomingCall(callId); + } + + const params = { + callId, + state: { + ...(this._preferences.mic !== undefined ? { mic: this._preferences.mic } : {}), + ...(this._preferences.cam !== undefined ? { cam: this._preferences.cam } : {}), + }, + }; + + const { url } = await APIClient.post('/v1/video-conference.join', params).catch((e) => { + debug && console.error(`[VideoConf] Failed to join call ${callId}`); + this.emit('join/error', { error: e?.xhr?.responseJSON?.error || 'unknown-error' }); + + return Promise.reject(e); + }); + + if (!url) { + throw new Error('Failed to get video conference URL.'); + } + + debug && console.log(`[VideoConf] Opening ${url}.`); + this.emit('call/join', { url, callId }); + } + + public abortCall(): void { + if (!this.currentCallData) { + return; + } + + this.giveUp(this.currentCallData); + } + + private rejectIncomingCallsFromUser(userId: string): void { + for (const [, { callId, uid }] of this.incomingDirectCalls) { + if (userId === uid) { + debug && console.log(`[VideoConf] Rejecting old incoming call from user ${userId}`); + this.rejectIncomingCall(callId); + } + } + } + + private async callUser({ uid, rid, callId }: DirectCallParams): Promise { + if (this.currentCallHandler || this.currentCallData) { + throw new Error('Video Conference State Error.'); + } + + let attempt = 1; + this.currentCallData = { callId, rid, uid }; + this.currentCallHandler = setInterval(() => { + if (!this.currentCallHandler) { + debug && console.warn(`[VideoConf] Ringing interval was not properly cleared.`); + return; + } + + attempt++; + + if (attempt > CALL_ATTEMPT_LIMIT) { + this.giveUp({ uid, rid, callId }); + return; + } + + debug && console.log(`[VideoConf] Ringing user ${uid}, attempt number ${attempt}.`); + Notifications.notifyUser(uid, 'video-conference.call', { uid: this.userId, rid, callId }); + }, CALL_INTERVAL); + this.emit('calling/changed'); + + debug && console.log(`[VideoConf] Ringing user ${uid} for the first time.`); + Notifications.notifyUser(uid, 'video-conference.call', { uid: this.userId, rid, callId }); + } + + private async giveUp({ uid, rid, callId }: DirectCallParams): Promise { + const joined = this.currentCallData?.joined; + + debug && console.log(`[VideoConf] Stop ringing user ${uid}.`); + if (this.currentCallHandler) { + clearInterval(this.currentCallHandler); + this.currentCallHandler = undefined; + this.currentCallData = undefined; + this.emit('calling/changed'); + } + + debug && console.log(`[VideoConf] Notifying user ${uid} that we are no longer calling.`); + Notifications.notifyUser(uid, 'video-conference.canceled', { uid: this.userId, rid, callId }); + + this.emit('direct/cancel', { uid, rid, callId }); + this.emit('direct/stopped', { uid, rid, callId }); + + if (joined) { + return; + } + + APIClient.post('/v1/video-conference.cancel', { callId }); + } + + private disconnect(): void { + debug && console.log(`[VideoConf] disconnecting user ${this.userId}`); + for (const hook of this.hooks) { + hook(); + } + this.hooks = []; + + if (this.currentCallHandler) { + clearInterval(this.currentCallHandler); + this.currentCallHandler = undefined; + } + + if (this.acceptingCallTimeout) { + clearTimeout(this.acceptingCallTimeout); + this.acceptingCallTimeout = 0; + } + + this.incomingDirectCalls.forEach((call) => { + if (call.timeout) { + clearTimeout(call.timeout); + } + }); + this.incomingDirectCalls.clear(); + this.currentCallData = undefined; + this.acceptingCallId = undefined; + this._preferences = {}; + this.emit('incoming/changed'); + this.emit('ringing/changed'); + this.emit('calling/changed'); + } + + private async hookNotification(eventName: string, cb: (...params: any[]) => void): Promise { + this.hooks.push(await Notifications.onUser(eventName, cb)); + } + + private connectUser(userId: string): void { + debug && console.log(`[VideoConf] connecting user ${userId}`); + this.userId = userId; + + this.hookNotification('video-conference.call', (params: DirectCallParams) => this.onDirectCall(params)); + this.hookNotification('video-conference.canceled', (params: DirectCallParams) => this.onDirectCallCanceled(params)); + this.hookNotification('video-conference.accepted', (params: DirectCallParams) => this.onDirectCallAccepted(params)); + this.hookNotification('video-conference.rejected', (params: DirectCallParams) => this.onDirectCallRejected(params)); + this.hookNotification('video-conference.confirmed', (params: DirectCallParams) => this.onDirectCallConfirmed(params)); + this.hookNotification('video-conference.join', (params: DirectCallParams) => this.onDirectCallJoined(params)); + } + + private abortIncomingCall(callId: string): void { + // If we just accepted this call, then ignore the timeout + if (this.acceptingCallId === callId) { + return; + } + + debug && console.log(`[VideoConf] Canceling call ${callId} due to ringing timeout.`); + this.loseIncomingCall(callId); + } + + private loseIncomingCall(callId: string): void { + const lostCall = this.incomingDirectCalls.get(callId); + if (!lostCall) { + debug && console.warn(`[VideoConf] Unable to cancel ${callId} because we have no information about it.`); + return; + } + + this.removeIncomingCall(callId); + + debug && console.log(`[VideoConf] Call ${callId} from ${lostCall.uid} was lost.`); + this.emit('direct/lost', { callId, uid: lostCall.uid, rid: lostCall.rid }); + } + + private removeIncomingCall(callId: string): void { + if (!this.incomingDirectCalls.has(callId)) { + return; + } + + const isRinging = this.isRinging(); + + const callData = this.incomingDirectCalls.get(callId); + if (callData?.timeout) { + clearTimeout(callData.timeout); + } + + this.incomingDirectCalls.delete(callId); + this.emit('incoming/changed'); + + if (isRinging !== this.isRinging()) { + this.emit('ringing/changed'); + } + } + + private createAbortTimeout(callId: string): number { + return setTimeout(() => this.abortIncomingCall(callId), CALL_TIMEOUT) as unknown as number; + } + + private startNewIncomingCall({ callId, uid, rid }: DirectCallParams): void { + if (this.isCallDismissed(callId)) { + debug && console.log(`[VideoConf] Ignoring dismissed call.`); + return; + } + + // Reject any currently ringing call from the user before registering the new one. + this.rejectIncomingCallsFromUser(uid); + + debug && console.log(`[VideoConf] Storing this new call information.`); + this.incomingDirectCalls.set(callId, { + callId, + uid, + rid, + timeout: this.createAbortTimeout(callId), + }); + + this.emit('incoming/changed'); + this.emit('ringing/changed'); + this.emit('direct/ringing', { callId, uid, rid }); + } + + private refreshExistingIncomingCall({ callId, uid, rid }: DirectCallParams): void { + const existingData = this.incomingDirectCalls.get(callId); + if (!existingData) { + throw new Error('Video Conference Manager State Error'); + } + + debug && console.log(`[VideoConf] Resetting call timeout.`); + if (existingData.timeout) { + clearTimeout(existingData.timeout); + } + existingData.timeout = this.createAbortTimeout(callId); + + if (!this.isCallDismissed(callId)) { + this.emit('direct/ringing', { callId, uid, rid }); + } + } + + private onDirectCall({ callId, uid, rid }: DirectCallParams): void { + // If we already accepted this call, then don't ring again + if (this.acceptingCallId === callId) { + return; + } + + debug && console.log(`[VideoConf] User ${uid} is ringing with call ${callId}.`); + if (this.incomingDirectCalls.has(callId)) { + this.refreshExistingIncomingCall({ callId, uid, rid }); + } else { + this.startNewIncomingCall({ callId, uid, rid }); + } + } + + private onDirectCallCanceled({ callId }: DirectCallParams): void { + debug && console.log(`[VideoConf] Call ${callId} was canceled by the remote user.`); + + // We had just accepted this call, but the remote user hang up before they got the notification, so cancel our acceptance + if (this.acceptingCallId === callId) { + if (this.acceptingCallTimeout) { + clearTimeout(this.acceptingCallTimeout); + } + this.acceptingCallTimeout = 0; + } + + this.loseIncomingCall(callId); + } + + private onDirectCallAccepted(params: DirectCallParams, skipConfirmation = false): void { + if (!params.callId || params.callId !== this.currentCallData?.callId) { + debug && console.log(`[VideoConf] User ${params.uid} has accepted a call ${params.callId} from us, but we're not calling.`); + return; + } + + debug && console.log(`[VideoConf] User ${params.uid} has accepted our call ${params.callId}.`); + + // Stop ringing + if (this.currentCallHandler) { + clearInterval(this.currentCallHandler); + this.currentCallHandler = undefined; + } + + const callData = this.currentCallData; + + this.emit('direct/accepted', params); + this.emit('direct/stopped', params); + this.currentCallData = undefined; + this.emit('calling/changed'); + + if (!callData.joined) { + this.joinCall(params.callId); + } + + if (skipConfirmation) { + return; + } + + debug && console.log(`[VideoConf] Notifying user ${callData.uid} that they can join the call now.`); + Notifications.notifyUser(callData.uid, 'video-conference.confirmed', { callId: callData.callId, uid: this.userId, rid: callData.rid }); + } + + private onDirectCallConfirmed(params: DirectCallParams): void { + if (!params.callId || params.callId !== this.acceptingCallId) { + debug && console.log(`[VideoConf] User ${params.uid} confirmed we can join ${params.callId} but we aren't trying to join it.`); + return; + } + + this.joinCall(params.callId); + } + + private onDirectCallJoined(params: DirectCallParams): void { + if (!params.callId) { + debug && console.log(`[VideoConf] Invalid 'video-conference.join' event received: ${params.callId}, ${params.uid}.`); + return; + } + + if (params.uid === this.userId) { + if (this.currentCallData?.callId === params.callId) { + debug && console.log(`[VideoConf] We joined our own call (${this.userId}) from somewhere else. Flagging the call appropriatelly.`); + this.currentCallData.joined = true; + this.emit('calling/changed'); + return; + } + + if (this.incomingDirectCalls.has(params.callId)) { + debug && console.log(`[VideoConf] We joined the call ${params.callId} from somewhere else. Dismissing it.`); + this.dismissIncomingCall(params.callId); + this.loseIncomingCall(params.callId); + } + return; + } + + debug && console.log(`[VideoConf] User ${params.uid} has joined a call we started ${params.callId}.`); + this.onDirectCallAccepted(params, true); + } + + private onDirectCallRejected(params: DirectCallParams): void { + if (!params.callId || params.callId !== this.currentCallData?.callId) { + debug && console.log(`[VideoConf] User ${params.uid} has rejected a call ${params.callId} from us, but we're not calling.`); + return; + } + + debug && console.log(`[VideoConf] User ${params.uid} has rejected our call ${params.callId}.`); + + // Stop ringing + if (this.currentCallHandler) { + clearInterval(this.currentCallHandler); + this.currentCallHandler = undefined; + } + + const { joined } = this.currentCallData; + + this.emit('direct/cancel', params); + this.currentCallData = undefined; + this.emit('direct/stopped', params); + this.emit('calling/changed'); + + if (!joined) { + APIClient.post('/v1/video-conference.cancel', { callId: params.callId }); + } + } + + private isCallDismissed(callId: string): boolean { + return Boolean(this.incomingDirectCalls.get(callId)?.dismissed); + } +})(); + +Meteor.startup(() => Tracker.autorun(() => VideoConfManager.updateUser())); diff --git a/apps/meteor/client/portals/VideoConfPopupPortal.ts b/apps/meteor/client/portals/VideoConfPopupPortal.ts new file mode 100644 index 000000000000..f6b6cff847a3 --- /dev/null +++ b/apps/meteor/client/portals/VideoConfPopupPortal.ts @@ -0,0 +1,17 @@ +import { memo, useEffect, ReactElement, ReactNode, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { createAnchor } from '../lib/utils/createAnchor'; +import { deleteAnchor } from '../lib/utils/deleteAnchor'; + +type VideoConfPortalProps = { + children?: ReactNode; +}; + +const VideoConfPortal = ({ children }: VideoConfPortalProps): ReactElement => { + const [videoConfRoot] = useState(() => createAnchor('video-conf-root')); + useEffect(() => (): void => deleteAnchor(videoConfRoot), [videoConfRoot]); + return createPortal(children, videoConfRoot); +}; + +export default memo(VideoConfPortal); diff --git a/apps/meteor/client/providers/CallProvider/CallProvider.tsx b/apps/meteor/client/providers/CallProvider/CallProvider.tsx index 29dd13c4fde4..a0d3fd0b118c 100644 --- a/apps/meteor/client/providers/CallProvider/CallProvider.tsx +++ b/apps/meteor/client/providers/CallProvider/CallProvider.tsx @@ -34,7 +34,7 @@ import { OutgoingByeRequest } from 'sip.js/lib/core'; import { CustomSounds } from '../../../app/custom-sounds/client'; import { getUserPreference } from '../../../app/utils/client'; -import { useHasLicense } from '../../../ee/client/hooks/useHasLicense'; +import { useHasLicenseModule } from '../../../ee/client/hooks/useHasLicenseModule'; import { WrapUpCallModal } from '../../../ee/client/voip/components/modals/WrapUpCallModal'; import { CallContext, CallContextValue } from '../../contexts/CallContext'; import { roomCoordinator } from '../../lib/rooms/roomCoordinator'; @@ -70,7 +70,7 @@ export const CallProvider: FC = ({ children }) => { const homeRoute = useRoute('home'); const setOutputMediaDevice = useSetOutputMediaDevice(); const setInputMediaDevice = useSetInputMediaDevice(); - const isEnterprise = useHasLicense('voip-enterprise'); + const isEnterprise = useHasLicenseModule('voip-enterprise'); const remoteAudioMediaRef = useRef(null); // TODO: Create a dedicated file for the AUDIO and make the controls accessible @@ -284,7 +284,7 @@ export const CallProvider: FC = ({ children }) => { remoteAudioMediaRef.current && result.voipClient.switchMediaRenderer({ remoteMediaElement: remoteAudioMediaRef.current }); }, [result.voipClient]); - const hasLicenseToMakeVoIPCalls = useHasLicense('voip-enterprise'); + const hasLicenseToMakeVoIPCalls = useHasLicenseModule('voip-enterprise'); useEffect(() => { if (!result.voipClient) { diff --git a/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts b/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts index c7cfcf0519c9..828b825721a6 100644 --- a/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts +++ b/apps/meteor/client/providers/CallProvider/hooks/useVoipClient.ts @@ -4,7 +4,7 @@ import { useUser, useSetting, useEndpoint, useStream } from '@rocket.chat/ui-con import { KJUR } from 'jsrsasign'; import { useEffect, useState } from 'react'; -import { useHasLicense } from '../../../../ee/client/hooks/useHasLicense'; +import { useHasLicenseModule } from '../../../../ee/client/hooks/useHasLicenseModule'; import { EEVoipClient } from '../../../../ee/client/lib/voip/EEVoipClient'; import { VoIPUser } from '../../../lib/voip/VoIPUser'; import { useWebRtcServers } from './useWebRtcServers'; @@ -32,7 +32,7 @@ export const useVoipClient = (): UseVoipClientResult => { const iceServers = useWebRtcServers(); const [result, setResult] = useSafely(useState({})); - const isEE = useHasLicense('voip-enterprise'); + const isEE = useHasLicenseModule('voip-enterprise'); useEffect(() => { const voipEnableEventHandler = (enabled: boolean): void => { diff --git a/apps/meteor/client/providers/MeteorProvider.tsx b/apps/meteor/client/providers/MeteorProvider.tsx index c10dc215fda6..ce10cc9ca1e1 100644 --- a/apps/meteor/client/providers/MeteorProvider.tsx +++ b/apps/meteor/client/providers/MeteorProvider.tsx @@ -18,6 +18,7 @@ import ToastMessagesProvider from './ToastMessagesProvider'; import TooltipProvider from './TooltipProvider'; import TranslationProvider from './TranslationProvider'; import UserProvider from './UserProvider'; +import VideoConfProvider from './VideoConfProvider'; const MeteorProvider: FC = ({ children }) => ( @@ -34,13 +35,15 @@ const MeteorProvider: FC = ({ children }) => ( - - - - {children} - - - + + + + + {children} + + + + diff --git a/apps/meteor/client/providers/VideoConfProvider.tsx b/apps/meteor/client/providers/VideoConfProvider.tsx new file mode 100644 index 000000000000..68492ddc0a14 --- /dev/null +++ b/apps/meteor/client/providers/VideoConfProvider.tsx @@ -0,0 +1,82 @@ +import { IRoom } from '@rocket.chat/core-typings'; +import { useSetModal } from '@rocket.chat/ui-contexts'; +import React, { ReactElement, useState, ReactNode, useMemo, useEffect } from 'react'; +import { Unsubscribe } from 'use-subscription'; + +import { VideoConfContext, VideoConfPopupPayload } from '../contexts/VideoConfContext'; +import { VideoConfManager, DirectCallParams, ProviderCapabilities, CallPreferences } from '../lib/VideoConfManager'; +import VideoConfBlockModal from '../views/room/contextualBar/VideoConference/VideoConfBlockModal'; +import VideoConfPopups from '../views/room/contextualBar/VideoConference/VideoConfPopups/VideoConfPopups'; + +const VideoConfContextProvider = ({ children }: { children: ReactNode }): ReactElement => { + const [outgoing, setOutgoing] = useState(); + const setModal = useSetModal(); + + useEffect( + () => + VideoConfManager.on('call/join', (props) => { + const open = (): void => { + const popup = window.open(props.url); + + if (popup !== null) { + return; + } + + setModal( setModal(null)} onConfirm={open} />); + }; + open(); + }), + [setModal], + ); + + useEffect(() => { + VideoConfManager.on('direct/stopped', () => setOutgoing(undefined)); + VideoConfManager.on('calling/ended', () => setOutgoing(undefined)); + }, []); + + const contextValue = useMemo( + () => ({ + dispatchOutgoing: (option: Omit): void => setOutgoing({ ...option, id: option.rid }), + dismissOutgoing: (): void => setOutgoing(undefined), + startCall: (rid: IRoom['_id'], confTitle?: string): Promise => VideoConfManager.startCall(rid, confTitle), + acceptCall: (callId: string): void => VideoConfManager.acceptIncomingCall(callId), + joinCall: (callId: string): Promise => VideoConfManager.joinCall(callId), + dismissCall: (callId: string): void => { + VideoConfManager.dismissIncomingCall(callId); + }, + rejectIncomingCall: (callId: string): void => VideoConfManager.rejectIncomingCall(callId), + abortCall: (): void => VideoConfManager.abortCall(), + setPreferences: (prefs: Partial): void => VideoConfManager.setPreferences(prefs), + queryIncomingCalls: { + getCurrentValue: (): DirectCallParams[] => VideoConfManager.getIncomingDirectCalls(), + subscribe: (cb: () => void): Unsubscribe => VideoConfManager.on('incoming/changed', cb), + }, + queryRinging: { + getCurrentValue: (): boolean => VideoConfManager.isRinging(), + subscribe: (cb: () => void): Unsubscribe => VideoConfManager.on('ringing/changed', cb), + }, + queryCalling: { + getCurrentValue: (): boolean => VideoConfManager.isCalling(), + subscribe: (cb: () => void): Unsubscribe => VideoConfManager.on('calling/changed', cb), + }, + queryCapabilities: { + getCurrentValue: (): ProviderCapabilities => VideoConfManager.capabilities, + subscribe: (cb: () => void): Unsubscribe => VideoConfManager.on('capabilities/changed', cb), + }, + queryPreferences: { + getCurrentValue: (): CallPreferences => VideoConfManager.preferences, + subscribe: (cb: () => void): Unsubscribe => VideoConfManager.on('preference/changed', cb), + }, + }), + [], + ); + + return ( + + {children} + {outgoing} + + ); +}; + +export default VideoConfContextProvider; diff --git a/apps/meteor/client/sidebar/RoomList/Row.tsx b/apps/meteor/client/sidebar/RoomList/Row.tsx index 5cbe5a707e2a..394c84f7783e 100644 --- a/apps/meteor/client/sidebar/RoomList/Row.tsx +++ b/apps/meteor/client/sidebar/RoomList/Row.tsx @@ -1,8 +1,9 @@ import { IRoom, ISubscription } from '@rocket.chat/core-typings'; import { SidebarSection } from '@rocket.chat/fuselage'; import { useTranslation } from '@rocket.chat/ui-contexts'; -import React, { ComponentType, memo, ReactElement } from 'react'; +import React, { ComponentType, memo, useMemo, ReactElement } from 'react'; +import { useVideoConfAcceptCall, useVideoConfRejectIncomingCall, useVideoConfIncomingCalls } from '../../contexts/VideoConfContext'; import { useAvatarTemplate } from '../hooks/useAvatarTemplate'; import { useTemplateByViewMode } from '../hooks/useTemplateByViewMode'; import OmnichannelSection from '../sections/OmnichannelSection'; @@ -27,6 +28,20 @@ type RoomListRowProps = { const Row = ({ data, item }: { data: RoomListRowProps; item: ISubscription & IRoom }): ReactElement => { const { extended, t, SideBarItemTemplate, AvatarTemplate, openedRoom, sidebarViewMode } = data; + const acceptCall = useVideoConfAcceptCall(); + const rejectCall = useVideoConfRejectIncomingCall(); + const incomingCalls = useVideoConfIncomingCalls(); + const currentCall = incomingCalls.find((call) => call.rid === item.rid); + + const videoConfActions = useMemo( + () => + currentCall && { + acceptCall: (): void => acceptCall(currentCall.callId), + rejectCall: (): void => rejectCall(currentCall.callId), + }, + [acceptCall, rejectCall, currentCall], + ); + if (typeof item === 'string') { const Section = sections[item]; return Section ? ( @@ -37,6 +52,7 @@ const Row = ({ data, item }: { data: RoomListRowProps; item: ISubscription & IRo ); } + return ( ); }; diff --git a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx index 104a0b97496f..67889bc3a4fc 100644 --- a/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx +++ b/apps/meteor/client/sidebar/RoomList/SideBarItemTemplateWithData.tsx @@ -7,7 +7,7 @@ import { isOmnichannelRoom, ISubscription, } from '@rocket.chat/core-typings'; -import { Badge, Sidebar } from '@rocket.chat/fuselage'; +import { Badge, Sidebar, SidebarItemAction } from '@rocket.chat/fuselage'; import { useLayout, useTranslation } from '@rocket.chat/ui-contexts'; import React, { AllHTMLAttributes, ComponentType, memo, ReactElement, ReactNode } from 'react'; @@ -41,7 +41,7 @@ type RoomListRowProps = { icon: ReactNode; title: ReactNode; avatar: ReactNode; - // actions: unknown; + actions: unknown; href: string; time?: Date; menu?: ReactNode; @@ -69,6 +69,9 @@ type RoomListRowProps = { selected?: boolean; sidebarViewMode?: unknown; + videoConfActions?: { + [action: string]: () => void; + }; }; function SideBarItemTemplateWithData({ @@ -76,13 +79,13 @@ function SideBarItemTemplateWithData({ id, selected, style, - extended, SideBarItemTemplate, AvatarTemplate, t, // sidebarViewMode, isAnonymous, + videoConfActions, }: RoomListRowProps): ReactElement { const { sidebar } = useLayout(); @@ -108,7 +111,7 @@ function SideBarItemTemplateWithData({ const icon = ( // TODO: Remove icon='at' - + ); @@ -148,6 +151,14 @@ function SideBarItemTemplateWithData({ style={style} badges={badges} avatar={AvatarTemplate && } + actions={ + videoConfActions && ( + <> + + + + ) + } menu={ !isAnonymous && !isQueued && diff --git a/apps/meteor/client/sidebar/footer/voip/index.tsx b/apps/meteor/client/sidebar/footer/voip/index.tsx index c57eded2a3ba..15323a9797e2 100644 --- a/apps/meteor/client/sidebar/footer/voip/index.tsx +++ b/apps/meteor/client/sidebar/footer/voip/index.tsx @@ -1,7 +1,7 @@ import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts'; import React, { ReactElement, useCallback, useMemo, useState } from 'react'; -import { useHasLicense } from '../../../../ee/client/hooks/useHasLicense'; +import { useHasLicenseModule } from '../../../../ee/client/hooks/useHasLicenseModule'; import { useCallActions, useCallCreateRoom, @@ -29,7 +29,7 @@ export const VoipFooter = (): ReactElement | null => { const [muted, setMuted] = useState(false); const [paused, setPaused] = useState(false); - const isEE = useHasLicense('voip-enterprise'); + const isEE = useHasLicenseModule('voip-enterprise'); const toggleMic = useCallback( (state: boolean) => { diff --git a/apps/meteor/client/sidebar/hooks/useRoomList.ts b/apps/meteor/client/sidebar/hooks/useRoomList.ts index 202c8afb29d9..865cb9d2b2d4 100644 --- a/apps/meteor/client/sidebar/hooks/useRoomList.ts +++ b/apps/meteor/client/sidebar/hooks/useRoomList.ts @@ -3,6 +3,7 @@ import { useDebouncedState } from '@rocket.chat/fuselage-hooks'; import { useUserPreference, useUserSubscriptions, useSetting } from '@rocket.chat/ui-contexts'; import { useEffect } from 'react'; +import { useVideoConfIncomingCalls } from '../../contexts/VideoConfContext'; import { useOmnichannelEnabled } from '../../hooks/omnichannel/useOmnichannelEnabled'; import { useQueuedInquiries } from '../../hooks/omnichannel/useQueuedInquiries'; import { useQueryOptions } from './useQueryOptions'; @@ -26,6 +27,8 @@ export const useRoomList = (): Array => { const inquiries = useQueuedInquiries(); + const incomingCalls = useVideoConfIncomingCalls(); + let queue: IRoom[] = emptyQueue; if (inquiries.enabled) { queue = inquiries.queue; @@ -33,6 +36,7 @@ export const useRoomList = (): Array => { useEffect(() => { setRoomList(() => { + const incomingCall = new Set(); const favorite = new Set(); const team = new Set(); const omnichannel = new Set(); @@ -44,6 +48,10 @@ export const useRoomList = (): Array => { const onHold = new Set(); rooms.forEach((room) => { + if (incomingCalls.find((call) => call.rid === room.rid)) { + return incomingCall.add(room); + } + if (sidebarShowUnread && (room.alert || room.unread) && !room.hideUnreadStatus) { return unread.add(room); } @@ -81,6 +89,7 @@ export const useRoomList = (): Array => { const groups = new Map(); showOmnichannel && groups.set('Omnichannel', []); + incomingCall.size && groups.set('Incoming Calls', incomingCall); showOmnichannel && inquiries.enabled && queue.length && groups.set('Incoming_Livechats', queue); showOmnichannel && omnichannel.size && groups.set('Open_Livechats', omnichannel); showOmnichannel && onHold.size && groups.set('On_Hold_Chats', onHold); @@ -96,6 +105,7 @@ export const useRoomList = (): Array => { }, [ rooms, showOmnichannel, + incomingCalls, inquiries.enabled, queue, sidebarShowUnread, diff --git a/apps/meteor/client/views/admin/apps/AppDetailsHeader.tsx b/apps/meteor/client/views/admin/apps/AppDetailsHeader.tsx index 93ff4c3f17be..c99413cd9867 100644 --- a/apps/meteor/client/views/admin/apps/AppDetailsHeader.tsx +++ b/apps/meteor/client/views/admin/apps/AppDetailsHeader.tsx @@ -35,12 +35,18 @@ const AppDetailsHeader = ({ app }: { app: App }): ReactElement => { {t('By_author', { author: author?.name })} - | {t('Version_version', { version })} |{' '} - - {t('Marketplace_app_last_updated', { - lastUpdated, - })} - + | + {t('Version_version', { version })} + {lastUpdated && ( + <> + | + + {t('Marketplace_app_last_updated', { + lastUpdated, + })} + + + )} diff --git a/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx b/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx index 479206cb3131..c6057ec5459c 100644 --- a/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx +++ b/apps/meteor/client/views/admin/info/DeploymentCard.stories.tsx @@ -231,6 +231,28 @@ export default { totalEncryptedMessages: 0, totalLinkInvitationUses: 0, totalManuallyAddedUsers: 0, + videoConf: { + videoConference: { + started: 0, + ended: 0, + }, + direct: { + calling: 0, + started: 0, + ended: 0, + }, + livechat: { + started: 0, + ended: 0, + }, + settings: { + provider: '', + dms: false, + channels: false, + groups: false, + teams: false, + }, + }, totalSubscriptionRoles: 0, totalUserRoles: 0, totalWebRTCCalls: 0, diff --git a/apps/meteor/client/views/admin/info/InformationPage.stories.tsx b/apps/meteor/client/views/admin/info/InformationPage.stories.tsx index 9be279660e3b..be131afc8558 100644 --- a/apps/meteor/client/views/admin/info/InformationPage.stories.tsx +++ b/apps/meteor/client/views/admin/info/InformationPage.stories.tsx @@ -261,6 +261,28 @@ export default { totalEncryptedMessages: 0, totalLinkInvitationUses: 0, totalManuallyAddedUsers: 0, + videoConf: { + videoConference: { + started: 0, + ended: 0, + }, + direct: { + calling: 0, + started: 0, + ended: 0, + }, + livechat: { + started: 0, + ended: 0, + }, + settings: { + provider: '', + dms: false, + channels: false, + groups: false, + teams: false, + }, + }, totalSubscriptionRoles: 0, totalUserRoles: 0, totalWebRTCCalls: 0, diff --git a/apps/meteor/client/views/admin/info/UsageCard.stories.tsx b/apps/meteor/client/views/admin/info/UsageCard.stories.tsx index 154889e0fce4..c4e5b04f201c 100644 --- a/apps/meteor/client/views/admin/info/UsageCard.stories.tsx +++ b/apps/meteor/client/views/admin/info/UsageCard.stories.tsx @@ -209,6 +209,28 @@ export default { totalEncryptedMessages: 0, totalLinkInvitationUses: 0, totalManuallyAddedUsers: 0, + videoConf: { + videoConference: { + started: 0, + ended: 0, + }, + direct: { + calling: 0, + started: 0, + ended: 0, + }, + livechat: { + started: 0, + ended: 0, + }, + settings: { + provider: '', + dms: false, + channels: false, + groups: false, + teams: false, + }, + }, totalSubscriptionRoles: 0, totalUserRoles: 0, totalWebRTCCalls: 0, diff --git a/apps/meteor/client/views/admin/info/UsageCard.tsx b/apps/meteor/client/views/admin/info/UsageCard.tsx index dd06bd8276ec..2825b56a5474 100644 --- a/apps/meteor/client/views/admin/info/UsageCard.tsx +++ b/apps/meteor/client/views/admin/info/UsageCard.tsx @@ -4,7 +4,7 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; import { useRoute, useTranslation } from '@rocket.chat/ui-contexts'; import React, { memo, ReactElement } from 'react'; -import { useHasLicense } from '../../../../ee/client/hooks/useHasLicense'; +import { useHasLicenseModule } from '../../../../ee/client/hooks/useHasLicenseModule'; import Card from '../../../components/Card'; import { UserStatus } from '../../../components/UserStatus'; import { useFormatMemorySize } from '../../../hooks/useFormatMemorySize'; @@ -25,7 +25,7 @@ const UsageCard = ({ statistics, vertical }: UsageCardProps): ReactElement => { router.push(); }); - const canViewEngagement = useHasLicense('engagement-dashboard'); + const canViewEngagement = useHasLicenseModule('engagement-dashboard'); return ( diff --git a/apps/meteor/client/views/admin/settings/MemoizedSetting.tsx b/apps/meteor/client/views/admin/settings/MemoizedSetting.tsx index 446958d390d2..7f077645343d 100644 --- a/apps/meteor/client/views/admin/settings/MemoizedSetting.tsx +++ b/apps/meteor/client/views/admin/settings/MemoizedSetting.tsx @@ -11,6 +11,7 @@ import FontSettingInput from './inputs/FontSettingInput'; import GenericSettingInput from './inputs/GenericSettingInput'; import IntSettingInput from './inputs/IntSettingInput'; import LanguageSettingInput from './inputs/LanguageSettingInput'; +import LookupSettingInput from './inputs/LookupSettingInput'; import MultiSelectSettingInput from './inputs/MultiSelectSettingInput'; import PasswordSettingInput from './inputs/PasswordSettingInput'; import RelativeUrlSettingInput from './inputs/RelativeUrlSettingInput'; @@ -36,6 +37,7 @@ const inputsByType: Record> = { asset: AssetSettingInput, roomPick: RoomPickSettingInput, timezone: SelectTimezoneSettingInput, + lookup: LookupSettingInput, date: GenericSettingInput, // @todo: implement group: GenericSettingInput, // @todo: implement }; diff --git a/apps/meteor/client/views/admin/settings/inputs/LookupSettingInput.tsx b/apps/meteor/client/views/admin/settings/inputs/LookupSettingInput.tsx new file mode 100644 index 000000000000..be8db62c1dcb --- /dev/null +++ b/apps/meteor/client/views/admin/settings/inputs/LookupSettingInput.tsx @@ -0,0 +1,70 @@ +import { Box, Field, Flex, Select } from '@rocket.chat/fuselage'; +import type { PathFor } from '@rocket.chat/rest-typings'; +import React, { ReactElement } from 'react'; + +import { AsyncState } from '../../../../hooks/useAsyncState'; +import { useEndpointData } from '../../../../hooks/useEndpointData'; +import ResetSettingButton from '../ResetSettingButton'; + +type LookupSettingInputProps = { + _id: string; + label: string; + value?: string; + lookupEndpoint: PathFor<'GET'>; + placeholder?: string; + readonly?: boolean; + autocomplete?: boolean; + disabled?: boolean; + hasResetButton?: boolean; + onChangeValue?: (value: string) => void; + onResetButtonClick?: () => void; +}; + +function LookupSettingInput({ + _id, + label, + value, + placeholder, + readonly, + autocomplete, + disabled, + lookupEndpoint, + hasResetButton, + onChangeValue, + onResetButtonClick, +}: LookupSettingInputProps): ReactElement { + const handleChange = (value: string): void => { + onChangeValue?.(value); + }; + + const { value: options } = useEndpointData(lookupEndpoint) as AsyncState<{ data: { key: string; label: string }[] }>; + const values = options?.data || []; + + return ( + <> + + + + {label} + + {hasResetButton && } + + + +